Add email confirmation

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-12 12:17:21 +01:00
parent 0e44d39998
commit 1a6e68f13c
11 changed files with 709 additions and 18 deletions

View File

@@ -40,6 +40,7 @@ const ControlOverviewPage = lazy(() => import("./pages/ControlOverviewPage"));
const PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const ConfirmEmailPage = lazy(() => import("./pages/ConfirmEmailPage"));
const CreateOrganizationPage = lazy(
() => import("./pages/CreateOrganizationPage")
);
@@ -103,6 +104,26 @@ function App() {
/>
</Route>
<Route
path="/confirm-email"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ConfirmEmailPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/"
element={

View File

@@ -0,0 +1,170 @@
import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Link } from "react-router";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { PayloadError } from "relay-runtime";
import { ConfirmEmailPageMutation } from "./__generated__/ConfirmEmailPageMutation.graphql";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const ConfirmEmailMutation = graphql`
mutation ConfirmEmailPageMutation($input: ConfirmEmailInput!) {
confirmEmail(input: $input) {
success
}
}
`;
export default function ConfirmEmailPage() {
const [isLoading, setIsLoading] = useState(false);
const [isConfirmed, setIsConfirmed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string>("");
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const [commitMutation] =
useMutation<ConfirmEmailPageMutation>(ConfirmEmailMutation);
useEffect(() => {
// Extract token from URL and prefill the form
const searchParams = new URLSearchParams(location.search);
const urlToken = searchParams.get("token");
if (urlToken) {
setToken(urlToken);
}
}, [location.search]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
if (!token.trim()) {
setError("Please enter a confirmation token");
setIsLoading(false);
return;
}
try {
commitMutation({
variables: {
input: {
token: token.trim(),
},
},
onCompleted: (response, errors: PayloadError[] | null) => {
if (errors) {
throw new Error(errors[0]?.message || "Failed to confirm email");
}
setIsConfirmed(true);
toast({
title: "Success",
description: "Your email has been confirmed successfully",
});
setIsLoading(false);
},
onError: (err) => {
setError(err.message || "Failed to confirm email. Please try again.");
setIsLoading(false);
},
});
} catch (error) {
setError(
error instanceof Error
? error.message
: "Failed to confirm email. Please try again."
);
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Confirm Email - Probo</title>
</Helmet>
<div className="flex flex-col items-center justify-center min-h-[70vh] p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">
Email Confirmation
</CardTitle>
<CardDescription className="text-center">
Confirm your email address to complete registration
</CardDescription>
</CardHeader>
<CardContent>
{isConfirmed ? (
<div className="space-y-4 text-center">
<p className="text-green-600 dark:text-green-400">
Your email has been confirmed successfully!
</p>
<Button onClick={() => navigate("/login")} className="w-full">
Proceed to Login
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="token">Confirmation Token</Label>
<Input
id="token"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your confirmation token"
disabled={isLoading}
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
The token has been automatically filled from the URL if
available
</p>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Confirming..." : "Confirm Email"}
</Button>
</form>
)}
</CardContent>
<CardFooter className="flex justify-center">
{!isConfirmed && (
<Link
to="/login"
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Back to Login
</Link>
)}
</CardFooter>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<3de8e69abff0cf5f1000d93dde7a3031>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ConfirmEmailInput = {
token: string;
};
export type ConfirmEmailPageMutation$variables = {
input: ConfirmEmailInput;
};
export type ConfirmEmailPageMutation$data = {
readonly confirmEmail: {
readonly success: boolean;
};
};
export type ConfirmEmailPageMutation = {
response: ConfirmEmailPageMutation$data;
variables: ConfirmEmailPageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "ConfirmEmailPayload",
"kind": "LinkedField",
"name": "confirmEmail",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ConfirmEmailPageMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ConfirmEmailPageMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "b6bde3a559a4ecb70a519ffb7fa83330",
"id": null,
"metadata": {},
"name": "ConfirmEmailPageMutation",
"operationKind": "mutation",
"text": "mutation ConfirmEmailPageMutation(\n $input: ConfirmEmailInput!\n) {\n confirmEmail(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "e7f442b5acc6d912bf272ca2f5dc1ef1";
export default node;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN email_address_verified BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ALTER COLUMN email_address_verified DROP DEFAULT;

View File

@@ -70,6 +70,7 @@ SELECT
id,
email_address,
hashed_password,
email_address_verified,
fullname,
created_at,
updated_at
@@ -111,6 +112,7 @@ SELECT
id,
email_address,
hashed_password,
email_address_verified,
fullname,
created_at,
updated_at
@@ -148,11 +150,12 @@ func (u *User) Insert(
) error {
q := `
INSERT INTO
users (id, email_address, hashed_password, fullname, created_at, updated_at)
users (id, email_address, hashed_password, email_address_verified, fullname, created_at, updated_at)
VALUES (
@user_id,
@email_address,
@hashed_password,
@email_address_verified,
@fullname,
@created_at,
@updated_at
@@ -160,12 +163,13 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"fullname": u.FullName,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
"user_id": u.ID,
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"fullname": u.FullName,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
"email_address_verified": u.EmailAddressVerified,
}
_, err := conn.Exec(ctx, q, args)
@@ -185,3 +189,35 @@ VALUES (
return nil
}
func (u *User) UpdateEmailVerification(
ctx context.Context,
conn pg.Conn,
verified bool,
) error {
q := `
UPDATE
users
SET
email_address_verified = @email_address_verified,
updated_at = @updated_at
WHERE
id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address_verified": verified,
"updated_at": time.Now(),
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
u.EmailAddressVerified = verified
u.UpdatedAt = args["updated_at"].(time.Time)
return nil
}

View File

@@ -48,11 +48,12 @@ type (
}
config struct {
Pg pgConfig `json:"pg"`
Api apiConfig `json:"api"`
Auth authConfig `json:"auth"`
AWS awsConfig `json:"aws"`
Mailer mailerConfig `json:"mailer"`
Hostname string `json:"hostname"`
Pg pgConfig `json:"pg"`
Api apiConfig `json:"api"`
Auth authConfig `json:"auth"`
AWS awsConfig `json:"aws"`
Mailer mailerConfig `json:"mailer"`
}
)
@@ -64,6 +65,7 @@ var (
func New() *Implm {
return &Implm{
cfg: config{
Hostname: "localhost:8080",
Api: apiConfig{
Addr: "localhost:8080",
Cors: corsConfig{
@@ -170,7 +172,13 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot create hashing profile: %w", err)
}
usrmgrService, err := usrmgr.NewService(ctx, pgClient, hp)
usrmgrService, err := usrmgr.NewService(
ctx,
pgClient,
hp,
impl.cfg.Auth.Cookie.Secret,
impl.cfg.Hostname,
)
if err != nil {
return fmt.Errorf("cannot create usrmgr service: %w", err)
}

View File

@@ -329,6 +329,8 @@ type Mutation {
createPolicy(input: CreatePolicyInput!): CreatePolicyPayload!
updatePolicy(input: UpdatePolicyInput!): UpdatePolicyPayload!
deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload!
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
}
input CreateVendorInput {
@@ -606,3 +608,11 @@ input UpdateTaskInput {
type UpdateTaskPayload {
task: Task!
}
input ConfirmEmailInput {
token: String!
}
type ConfirmEmailPayload {
success: Boolean!
}

View File

@@ -57,6 +57,10 @@ type DirectiveRoot struct {
}
type ComplexityRoot struct {
ConfirmEmailPayload struct {
Success func(childComplexity int) int
}
Control struct {
Category func(childComplexity int) int
CreatedAt func(childComplexity int) int
@@ -173,6 +177,7 @@ type ComplexityRoot struct {
}
Mutation struct {
ConfirmEmail func(childComplexity int, input types.ConfirmEmailInput) int
CreateControl func(childComplexity int, input types.CreateControlInput) int
CreateFramework func(childComplexity int, input types.CreateFrameworkInput) int
CreateOrganization func(childComplexity int, input types.CreateOrganizationInput) int
@@ -392,6 +397,7 @@ type MutationResolver interface {
CreatePolicy(ctx context.Context, input types.CreatePolicyInput) (*types.CreatePolicyPayload, error)
UpdatePolicy(ctx context.Context, input types.UpdatePolicyInput) (*types.UpdatePolicyPayload, error)
DeletePolicy(ctx context.Context, input types.DeletePolicyInput) (*types.DeletePolicyPayload, error)
ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error)
}
type OrganizationResolver interface {
Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error)
@@ -432,6 +438,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
_ = ec
switch typeName + "." + field {
case "ConfirmEmailPayload.success":
if e.complexity.ConfirmEmailPayload.Success == nil {
break
}
return e.complexity.ConfirmEmailPayload.Success(childComplexity), true
case "Control.category":
if e.complexity.Control.Category == nil {
break
@@ -785,6 +798,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.FrameworkEdge.Node(childComplexity), true
case "Mutation.confirmEmail":
if e.complexity.Mutation.ConfirmEmail == nil {
break
}
args, err := ec.field_Mutation_confirmEmail_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.ConfirmEmail(childComplexity, args["input"].(types.ConfirmEmailInput)), true
case "Mutation.createControl":
if e.complexity.Mutation.CreateControl == nil {
break
@@ -1684,6 +1709,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
opCtx := graphql.GetOperationContext(ctx)
ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)}
inputUnmarshalMap := graphql.BuildUnmarshalerMap(
ec.unmarshalInputConfirmEmailInput,
ec.unmarshalInputCreateControlInput,
ec.unmarshalInputCreateFrameworkInput,
ec.unmarshalInputCreateOrganizationInput,
@@ -2132,6 +2158,8 @@ type Mutation {
createPolicy(input: CreatePolicyInput!): CreatePolicyPayload!
updatePolicy(input: UpdatePolicyInput!): UpdatePolicyPayload!
deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload!
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
}
input CreateVendorInput {
@@ -2409,6 +2437,14 @@ input UpdateTaskInput {
type UpdateTaskPayload {
task: Task!
}
input ConfirmEmailInput {
token: String!
}
type ConfirmEmailPayload {
success: Boolean!
}
`, BuiltIn: false},
}
var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -2571,6 +2607,29 @@ func (ec *executionContext) field_Framework_controls_argsBefore(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_confirmEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_confirmEmail_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_confirmEmail_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.ConfirmEmailInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNConfirmEmailInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmEmailInput(ctx, tmp)
}
var zeroVal types.ConfirmEmailInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_createControl_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -3639,6 +3698,44 @@ func (ec *executionContext) field___Type_fields_argsIncludeDeprecated(
// region **************************** field.gotpl *****************************
func (ec *executionContext) _ConfirmEmailPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.ConfirmEmailPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ConfirmEmailPayload_success(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_ConfirmEmailPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "ConfirmEmailPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Control_id(ctx context.Context, field graphql.CollectedField, obj *types.Control) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Control_id(ctx, field)
if err != nil {
@@ -6607,6 +6704,53 @@ func (ec *executionContext) fieldContext_Mutation_deletePolicy(ctx context.Conte
return fc, nil
}
func (ec *executionContext) _Mutation_confirmEmail(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_confirmEmail(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().ConfirmEmail(rctx, fc.Args["input"].(types.ConfirmEmailInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.ConfirmEmailPayload)
fc.Result = res
return ec.marshalNConfirmEmailPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmEmailPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_confirmEmail(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "success":
return ec.fieldContext_ConfirmEmailPayload_success(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type ConfirmEmailPayload", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_confirmEmail_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Organization_id(ctx, field)
if err != nil {
@@ -12155,6 +12299,33 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field
// region **************************** input.gotpl *****************************
func (ec *executionContext) unmarshalInputConfirmEmailInput(ctx context.Context, obj any) (types.ConfirmEmailInput, error) {
var it types.ConfirmEmailInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"token"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "token":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.Token = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputCreateControlInput(ctx context.Context, obj any) (types.CreateControlInput, error) {
var it types.CreateControlInput
asMap := map[string]any{}
@@ -13195,6 +13366,45 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj
// region **************************** object.gotpl ****************************
var confirmEmailPayloadImplementors = []string{"ConfirmEmailPayload"}
func (ec *executionContext) _ConfirmEmailPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ConfirmEmailPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, confirmEmailPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("ConfirmEmailPayload")
case "success":
out.Values[i] = ec._ConfirmEmailPayload_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var controlImplementors = []string{"Control", "Node"}
func (ec *executionContext) _Control(ctx context.Context, sel ast.SelectionSet, obj *types.Control) graphql.Marshaler {
@@ -14425,6 +14635,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "confirmEmail":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_confirmEmail(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -16323,6 +16540,25 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se
return res
}
func (ec *executionContext) unmarshalNConfirmEmailInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmEmailInput(ctx context.Context, v any) (types.ConfirmEmailInput, error) {
res, err := ec.unmarshalInputConfirmEmailInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNConfirmEmailPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmEmailPayload(ctx context.Context, sel ast.SelectionSet, v types.ConfirmEmailPayload) graphql.Marshaler {
return ec._ConfirmEmailPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNConfirmEmailPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmEmailPayload(ctx context.Context, sel ast.SelectionSet, v *types.ConfirmEmailPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._ConfirmEmailPayload(ctx, sel, v)
}
func (ec *executionContext) marshalNControl2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐControl(ctx context.Context, sel ast.SelectionSet, v *types.Control) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {

View File

@@ -16,6 +16,14 @@ type Node interface {
GetID() gid.GID
}
type ConfirmEmailInput struct {
Token string `json:"token"`
}
type ConfirmEmailPayload struct {
Success bool `json:"success"`
}
type Control struct {
ID gid.GID `json:"id"`
Version int `json:"version"`

View File

@@ -423,6 +423,17 @@ func (r *mutationResolver) DeletePolicy(ctx context.Context, input types.DeleteP
}, nil
}
// ConfirmEmail is the resolver for the confirmEmail field.
func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error) {
err := r.usrmgrSvc.ConfirmEmail(ctx, input.Token)
if err != nil {
return nil, err
}
return &types.ConfirmEmailPayload{Success: true}, nil
}
// Frameworks is the resolver for the frameworks field.
func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())

View File

@@ -18,20 +18,23 @@ import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg"
)
type (
Service struct {
pg *pg.Client
hp *passwdhash.Profile
pg *pg.Client
hp *passwdhash.Profile
hostname string
tokenSecret string
}
ErrInvalidCredentials struct {
@@ -61,6 +64,31 @@ type (
ErrSessionExpired struct {
message string
}
ErrInvalidTokenType struct {
message string
}
EmailConfirmationData struct {
UserID gid.GID `json:"uid"`
Email string `json:"email"`
}
)
// Token types
const (
TokenTypeEmailConfirmation = "email_confirmation"
TokenTypePasswordReset = "password_reset"
)
var (
signupEmailSubject = "Confirm your email address"
signupEmailTemplate = `
Thanks joining Probo!
Please confirm your email address by clicking the link below[1]
[1] %s
`
)
func (e ErrInvalidCredentials) Error() string {
@@ -91,14 +119,22 @@ func (e ErrInvalidFullName) Error() string {
return fmt.Sprintf("invalid full name: %s", e.fullName)
}
func (e ErrInvalidTokenType) Error() string {
return e.message
}
func NewService(
ctx context.Context,
pgClient *pg.Client,
hp *passwdhash.Profile,
tokenSecret string,
hostname string,
) (*Service, error) {
return &Service{
pg: pgClient,
hp: hp,
pg: pgClient,
hp: hp,
hostname: hostname,
tokenSecret: tokenSecret,
}, nil
}
@@ -141,6 +177,31 @@ func (s Service) SignUp(
UpdatedAt: now,
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
1*time.Hour,
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
}
confirmationEmailUrl := url.URL{
Host: s.hostname,
Path: "/confirm-email",
RawQuery: url.Values{
"token": []string{confirmationToken},
}.Encode(),
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
signupEmailSubject,
fmt.Sprintf(signupEmailTemplate, confirmationEmailUrl.String()),
)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
@@ -152,6 +213,10 @@ func (s Service) SignUp(
return fmt.Errorf("cannot insert session: %w", err)
}
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
@@ -394,3 +459,35 @@ func (s Service) UpdateSession(
},
)
}
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
token, err := statelesstoken.ValidateToken[EmailConfirmationData](
s.tokenSecret,
TokenTypeEmailConfirmation,
tokenString,
)
if err != nil {
return fmt.Errorf("cannot validate email confirmation token: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, token.Data.UserID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.EmailAddress != token.Data.Email {
return fmt.Errorf("token email does not match user email")
}
if err := user.UpdateEmailVerification(ctx, tx, true); err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
return nil
},
)
}