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 PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage")); const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage")); const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const ConfirmEmailPage = lazy(() => import("./pages/ConfirmEmailPage"));
const CreateOrganizationPage = lazy( const CreateOrganizationPage = lazy(
() => import("./pages/CreateOrganizationPage") () => import("./pages/CreateOrganizationPage")
); );
@@ -103,6 +104,26 @@ function App() {
/> />
</Route> </Route>
<Route
path="/confirm-email"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ConfirmEmailPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route <Route
path="/" path="/"
element={ 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, id,
email_address, email_address,
hashed_password, hashed_password,
email_address_verified,
fullname, fullname,
created_at, created_at,
updated_at updated_at
@@ -111,6 +112,7 @@ SELECT
id, id,
email_address, email_address,
hashed_password, hashed_password,
email_address_verified,
fullname, fullname,
created_at, created_at,
updated_at updated_at
@@ -148,11 +150,12 @@ func (u *User) Insert(
) error { ) error {
q := ` q := `
INSERT INTO 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 ( VALUES (
@user_id, @user_id,
@email_address, @email_address,
@hashed_password, @hashed_password,
@email_address_verified,
@fullname, @fullname,
@created_at, @created_at,
@updated_at @updated_at
@@ -166,6 +169,7 @@ VALUES (
"fullname": u.FullName, "fullname": u.FullName,
"created_at": u.CreatedAt, "created_at": u.CreatedAt,
"updated_at": u.UpdatedAt, "updated_at": u.UpdatedAt,
"email_address_verified": u.EmailAddressVerified,
} }
_, err := conn.Exec(ctx, q, args) _, err := conn.Exec(ctx, q, args)
@@ -185,3 +189,35 @@ VALUES (
return nil 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,6 +48,7 @@ type (
} }
config struct { config struct {
Hostname string `json:"hostname"`
Pg pgConfig `json:"pg"` Pg pgConfig `json:"pg"`
Api apiConfig `json:"api"` Api apiConfig `json:"api"`
Auth authConfig `json:"auth"` Auth authConfig `json:"auth"`
@@ -64,6 +65,7 @@ var (
func New() *Implm { func New() *Implm {
return &Implm{ return &Implm{
cfg: config{ cfg: config{
Hostname: "localhost:8080",
Api: apiConfig{ Api: apiConfig{
Addr: "localhost:8080", Addr: "localhost:8080",
Cors: corsConfig{ Cors: corsConfig{
@@ -170,7 +172,13 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot create hashing profile: %w", err) 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 { if err != nil {
return fmt.Errorf("cannot create usrmgr service: %w", err) return fmt.Errorf("cannot create usrmgr service: %w", err)
} }

View File

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

View File

@@ -57,6 +57,10 @@ type DirectiveRoot struct {
} }
type ComplexityRoot struct { type ComplexityRoot struct {
ConfirmEmailPayload struct {
Success func(childComplexity int) int
}
Control struct { Control struct {
Category func(childComplexity int) int Category func(childComplexity int) int
CreatedAt func(childComplexity int) int CreatedAt func(childComplexity int) int
@@ -173,6 +177,7 @@ type ComplexityRoot struct {
} }
Mutation struct { Mutation struct {
ConfirmEmail func(childComplexity int, input types.ConfirmEmailInput) int
CreateControl func(childComplexity int, input types.CreateControlInput) int CreateControl func(childComplexity int, input types.CreateControlInput) int
CreateFramework func(childComplexity int, input types.CreateFrameworkInput) int CreateFramework func(childComplexity int, input types.CreateFrameworkInput) int
CreateOrganization func(childComplexity int, input types.CreateOrganizationInput) 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) CreatePolicy(ctx context.Context, input types.CreatePolicyInput) (*types.CreatePolicyPayload, error)
UpdatePolicy(ctx context.Context, input types.UpdatePolicyInput) (*types.UpdatePolicyPayload, error) UpdatePolicy(ctx context.Context, input types.UpdatePolicyInput) (*types.UpdatePolicyPayload, error)
DeletePolicy(ctx context.Context, input types.DeletePolicyInput) (*types.DeletePolicyPayload, error) DeletePolicy(ctx context.Context, input types.DeletePolicyInput) (*types.DeletePolicyPayload, error)
ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error)
} }
type OrganizationResolver interface { type OrganizationResolver interface {
Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) 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 _ = ec
switch typeName + "." + field { switch typeName + "." + field {
case "ConfirmEmailPayload.success":
if e.complexity.ConfirmEmailPayload.Success == nil {
break
}
return e.complexity.ConfirmEmailPayload.Success(childComplexity), true
case "Control.category": case "Control.category":
if e.complexity.Control.Category == nil { if e.complexity.Control.Category == nil {
break break
@@ -785,6 +798,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.FrameworkEdge.Node(childComplexity), true 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": case "Mutation.createControl":
if e.complexity.Mutation.CreateControl == nil { if e.complexity.Mutation.CreateControl == nil {
break break
@@ -1684,6 +1709,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
opCtx := graphql.GetOperationContext(ctx) opCtx := graphql.GetOperationContext(ctx)
ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)}
inputUnmarshalMap := graphql.BuildUnmarshalerMap( inputUnmarshalMap := graphql.BuildUnmarshalerMap(
ec.unmarshalInputConfirmEmailInput,
ec.unmarshalInputCreateControlInput, ec.unmarshalInputCreateControlInput,
ec.unmarshalInputCreateFrameworkInput, ec.unmarshalInputCreateFrameworkInput,
ec.unmarshalInputCreateOrganizationInput, ec.unmarshalInputCreateOrganizationInput,
@@ -2132,6 +2158,8 @@ type Mutation {
createPolicy(input: CreatePolicyInput!): CreatePolicyPayload! createPolicy(input: CreatePolicyInput!): CreatePolicyPayload!
updatePolicy(input: UpdatePolicyInput!): UpdatePolicyPayload! updatePolicy(input: UpdatePolicyInput!): UpdatePolicyPayload!
deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload! deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload!
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
} }
input CreateVendorInput { input CreateVendorInput {
@@ -2409,6 +2437,14 @@ input UpdateTaskInput {
type UpdateTaskPayload { type UpdateTaskPayload {
task: Task! task: Task!
} }
input ConfirmEmailInput {
token: String!
}
type ConfirmEmailPayload {
success: Boolean!
}
`, BuiltIn: false}, `, BuiltIn: false},
} }
var parsedSchema = gqlparser.MustLoadSchema(sources...) var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -2571,6 +2607,29 @@ func (ec *executionContext) field_Framework_controls_argsBefore(
return zeroVal, nil 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) { func (ec *executionContext) field_Mutation_createControl_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error var err error
args := map[string]any{} args := map[string]any{}
@@ -3639,6 +3698,44 @@ func (ec *executionContext) field___Type_fields_argsIncludeDeprecated(
// region **************************** field.gotpl ***************************** // 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) { 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) fc, err := ec.fieldContext_Control_id(ctx, field)
if err != nil { if err != nil {
@@ -6607,6 +6704,53 @@ func (ec *executionContext) fieldContext_Mutation_deletePolicy(ctx context.Conte
return fc, nil 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) { 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) fc, err := ec.fieldContext_Organization_id(ctx, field)
if err != nil { if err != nil {
@@ -12155,6 +12299,33 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field
// region **************************** input.gotpl ***************************** // 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) { func (ec *executionContext) unmarshalInputCreateControlInput(ctx context.Context, obj any) (types.CreateControlInput, error) {
var it types.CreateControlInput var it types.CreateControlInput
asMap := map[string]any{} asMap := map[string]any{}
@@ -13195,6 +13366,45 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj
// region **************************** object.gotpl **************************** // 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"} var controlImplementors = []string{"Control", "Node"}
func (ec *executionContext) _Control(ctx context.Context, sel ast.SelectionSet, obj *types.Control) graphql.Marshaler { 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 { if out.Values[i] == graphql.Null {
out.Invalids++ 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: default:
panic("unknown field " + strconv.Quote(field.Name)) panic("unknown field " + strconv.Quote(field.Name))
} }
@@ -16323,6 +16540,25 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se
return res 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 { 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 v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {

View File

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

View File

@@ -423,6 +423,17 @@ func (r *mutationResolver) DeletePolicy(ctx context.Context, input types.DeleteP
}, nil }, 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. // 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) { 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()) svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())

View File

@@ -18,13 +18,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash" "github.com/getprobo/probo/pkg/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5" "github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
) )
@@ -32,6 +33,8 @@ type (
Service struct { Service struct {
pg *pg.Client pg *pg.Client
hp *passwdhash.Profile hp *passwdhash.Profile
hostname string
tokenSecret string
} }
ErrInvalidCredentials struct { ErrInvalidCredentials struct {
@@ -61,6 +64,31 @@ type (
ErrSessionExpired struct { ErrSessionExpired struct {
message string 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 { func (e ErrInvalidCredentials) Error() string {
@@ -91,14 +119,22 @@ func (e ErrInvalidFullName) Error() string {
return fmt.Sprintf("invalid full name: %s", e.fullName) return fmt.Sprintf("invalid full name: %s", e.fullName)
} }
func (e ErrInvalidTokenType) Error() string {
return e.message
}
func NewService( func NewService(
ctx context.Context, ctx context.Context,
pgClient *pg.Client, pgClient *pg.Client,
hp *passwdhash.Profile, hp *passwdhash.Profile,
tokenSecret string,
hostname string,
) (*Service, error) { ) (*Service, error) {
return &Service{ return &Service{
pg: pgClient, pg: pgClient,
hp: hp, hp: hp,
hostname: hostname,
tokenSecret: tokenSecret,
}, nil }, nil
} }
@@ -141,6 +177,31 @@ func (s Service) SignUp(
UpdatedAt: now, 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( err = s.pg.WithTx(
ctx, ctx,
func(tx pg.Conn) error { func(tx pg.Conn) error {
@@ -152,6 +213,10 @@ func (s Service) SignUp(
return fmt.Errorf("cannot insert session: %w", err) 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 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
},
)
}