diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 432e4bcc1..bc3451467 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -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() { /> + + + + } + > + + + + + + } + /> + + (null); + const [token, setToken] = useState(""); + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + const [commitMutation] = + useMutation(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 ( + <> + + Confirm Email - Probo + + +
+ + + + Email Confirmation + + + Confirm your email address to complete registration + + + + + {isConfirmed ? ( +
+

+ Your email has been confirmed successfully! +

+ +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setToken(e.target.value)} + placeholder="Enter your confirmation token" + disabled={isLoading} + required + /> +

+ The token has been automatically filled from the URL if + available +

+
+ + +
+ )} +
+ + + {!isConfirmed && ( + + Back to Login + + )} + +
+
+ + ); +} diff --git a/apps/console/src/pages/__generated__/ConfirmEmailPageMutation.graphql.ts b/apps/console/src/pages/__generated__/ConfirmEmailPageMutation.graphql.ts new file mode 100644 index 000000000..95108e081 --- /dev/null +++ b/apps/console/src/pages/__generated__/ConfirmEmailPageMutation.graphql.ts @@ -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; diff --git a/pkg/coredata/migrations/20250311T145900Z.sql b/pkg/coredata/migrations/20250311T145900Z.sql new file mode 100644 index 000000000..8c2027e48 --- /dev/null +++ b/pkg/coredata/migrations/20250311T145900Z.sql @@ -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; \ No newline at end of file diff --git a/pkg/coredata/user.go b/pkg/coredata/user.go index c3cfa5f2c..528da9a56 100644 --- a/pkg/coredata/user.go +++ b/pkg/coredata/user.go @@ -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 +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 130825e44..b8c7779b7 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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) } diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 99f849199..7a39dee4e 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -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! +} diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index d58c7f1ff..c61733212 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -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)) { diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 38d0ca321..4793e2eb9 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -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"` diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 8cfd60abd..b506e4dc3 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -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()) diff --git a/pkg/usrmgr/usrmgr.go b/pkg/usrmgr/usrmgr.go index 94b6b8c15..292097f7f 100644 --- a/pkg/usrmgr/usrmgr.go +++ b/pkg/usrmgr/usrmgr.go @@ -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 + }, + ) +}