mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
Sign in through Hanzo IAM only, and make accounts for invited people only
The IAM callback at /auth/callback reads IAM's userinfo answer and refuses an address IAM has not verified. An account is found by its linked IAM identity alone (provider hanzo, the IAM subject); an existing account that only shares the address is refused, never merged. A first sign-in makes an account only for an admin of the org the application serves or for someone an organisation invited at that verified address. Every refusal returns to /signin with a sentence saying what to do. Accepting an organisation invitation joins the signed-in person, and only when their verified address is the invited one; opening the link no longer adds whoever holds the address. The local email/password sign-up, sign-in, reset, verify-email and update routes, pages, jobs and mail are gone, as are passkey sign-in, the Google, Microsoft and generic OIDC providers, the per-organisation SSO portal and its account-link flow. Passkeys and two-factor stay for confirming a signature. The e2e suite starts sessions the way a finished IAM sign-in leaves them. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-16
@@ -17,22 +17,7 @@ IAM_CLIENT_ID=""
|
||||
IAM_CLIENT_SECRET=""
|
||||
# NEXT_PUBLIC_IAM_PROVIDER_NAME="Hanzo"
|
||||
|
||||
# [[AUTH OPTIONAL]]
|
||||
# Find documentation on setting up Google OAuth here:
|
||||
# https://docs.sign.hanzo.ai/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
# Find documentation on setting up Microsoft OAuth here:
|
||||
# https://docs.sign.hanzo.ai/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||
# Specifies the prompt to use for OIDC signin, explicitly setting
|
||||
# The prompt sent to Hanzo IAM at sign-in; explicitly setting
|
||||
# an empty string will omit the prompt parameter.
|
||||
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
|
||||
NEXT_PRIVATE_OIDC_PROMPT="login"
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { cn } from '@hanzo/esign-ui/lib/utils';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo/esign-ui/primitives/form/form';
|
||||
import { Input } from '@hanzo/esign-ui/primitives/input';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
export type ForgotPasswordFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<TForgotPasswordFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZForgotPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
|
||||
await authClient.emailPassword.forgotPassword({ email }).catch(() => null);
|
||||
|
||||
await navigate('/check-email');
|
||||
|
||||
toast({
|
||||
title: _(msg`Reset email sent`),
|
||||
description: _(
|
||||
msg`A password reset email has been sent, if you have an account you should see it in your inbox shortly.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
{isSubmitting ? <Trans>Sending Reset Email...</Trans> : <Trans>Reset Password</Trans>}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import type { SessionUser } from '@hanzo/esign-auth/server/lib/session/session';
|
||||
import { AppError } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@hanzo/esign-trpc/server/auth-router/schema';
|
||||
import { cn } from '@hanzo/esign-ui/lib/utils';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo/esign-ui/primitives/form/form';
|
||||
import { PasswordInput } from '@hanzo/esign-ui/primitives/password-input';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatedPassword'],
|
||||
});
|
||||
|
||||
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
|
||||
|
||||
export type PasswordFormProps = {
|
||||
className?: string;
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TPasswordFormSchema>({
|
||||
values: {
|
||||
currentPassword: '',
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
},
|
||||
resolver: zodResolver(ZPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
|
||||
try {
|
||||
await authClient.emailPassword.updatePassword({
|
||||
currentPassword,
|
||||
password,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
|
||||
toast({
|
||||
title: _(msg`Password updated`),
|
||||
description: _(msg`Your password has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('NO_PASSWORD', () => msg`User has no password.`)
|
||||
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
|
||||
.with(
|
||||
'SAME_PASSWORD',
|
||||
() => msg`Your new password cannot be the same as your old password.`,
|
||||
)
|
||||
.otherwise(
|
||||
() =>
|
||||
msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Current Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repeatedPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Repeat Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="ml-auto mt-4">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? <Trans>Updating password...</Trans> : <Trans>Update password</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { ZPasswordSchema } from '@hanzo/esign-trpc/server/auth-router/schema';
|
||||
import { cn } from '@hanzo/esign-ui/lib/utils';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo/esign-ui/primitives/form/form';
|
||||
import { PasswordInput } from '@hanzo/esign-ui/primitives/password-input';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export const ZResetPasswordFormSchema = z
|
||||
.object({
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
path: ['repeatedPassword'],
|
||||
message: "Passwords don't match",
|
||||
});
|
||||
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
className?: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TResetPasswordFormSchema>({
|
||||
values: {
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
},
|
||||
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
|
||||
try {
|
||||
await authClient.emailPassword.resetPassword({
|
||||
password,
|
||||
token,
|
||||
});
|
||||
|
||||
await navigate('/signin');
|
||||
|
||||
form.reset();
|
||||
|
||||
toast({
|
||||
title: _(msg`Password updated`),
|
||||
description: _(msg`Your password has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(AppErrorCode.EXPIRED_CODE, () => msg`Token has expired. Please try again.`)
|
||||
.with('INVALID_TOKEN', () => msg`Invalid token provided. Please try again.`)
|
||||
.with(
|
||||
'SAME_PASSWORD',
|
||||
() => msg`Your new password cannot be the same as your old password.`,
|
||||
)
|
||||
.otherwise(
|
||||
() =>
|
||||
msg`We encountered an unknown error while attempting to reset your password. Please try again later.`,
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repeatedPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Repeat Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||
{isSubmitting ? <Trans>Resetting Password...</Trans> : <Trans>Reset Password</Trans>}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { cn } from '@hanzo/esign-ui/lib/utils';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo/esign-ui/primitives/form/form';
|
||||
import { Input } from '@hanzo/esign-ui/primitives/input';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export const ZSendConfirmationEmailFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
||||
|
||||
export type SendConfirmationEmailFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TSendConfirmationEmailFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
|
||||
try {
|
||||
await authClient.emailPassword.resendVerifyEmail({ email });
|
||||
|
||||
toast({
|
||||
title: _(msg`Confirmation email sent`),
|
||||
description: _(
|
||||
msg`A confirmation email has been sent, and it should arrive in your inbox shortly.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`An error occurred while sending your confirmation email`),
|
||||
description: _(msg`Please try again and make sure you enter the correct email address.`),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
<Trans>Send confirmation email</Trans>
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -10,30 +10,20 @@ import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export type DocumentSigningAuthPageViewProps = {
|
||||
email?: string;
|
||||
emailHasAccount?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthPageView = ({
|
||||
email,
|
||||
emailHasAccount,
|
||||
}: DocumentSigningAuthPageViewProps) => {
|
||||
export const DocumentSigningAuthPageView = ({ email }: DocumentSigningAuthPageViewProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleChangeAccount = async (email?: string) => {
|
||||
const handleChangeAccount = async () => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
let redirectPath = '/signin';
|
||||
|
||||
if (email) {
|
||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||
}
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath,
|
||||
redirectPath: '/signin',
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@@ -54,7 +44,7 @@ export const DocumentSigningAuthPageView = ({
|
||||
<Trans>Authentication required</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{email ? (
|
||||
<Trans>
|
||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||
@@ -67,10 +57,10 @@ export const DocumentSigningAuthPageView = ({
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount(email)}
|
||||
onClick={handleChangeAccount}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
{emailHasAccount ? <Trans>Login</Trans> : <Trans>Sign up</Trans>}
|
||||
<Trans>Sign in with Hanzo</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { ONE_DAY, ONE_SECOND } from '@hanzo/esign-lib/constants/time';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@hanzo/esign-ui/primitives/dialog';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
export type VerifyEmailBannerProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||
|
||||
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||
|
||||
const onResendConfirmationEmail = async () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
setIsButtonDisabled(true);
|
||||
await authClient.emailPassword.resendVerifyEmail({ email: email });
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Verification email sent successfully.`),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||
} catch (err) {
|
||||
setIsButtonDisabled(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Something went wrong while sending the confirmation email.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||
// if it was within the past 24 hours, don't show it again
|
||||
// otherwise, show it again and update the localStorage timestamp
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-foreground">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-background">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
<Trans>Verify your email address to unlock all features.</Trans>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto px-2.5 py-1.5 text-background hover:bg-background/20 hover:text-background"
|
||||
disabled={isButtonDisabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isButtonDisabled ? (
|
||||
<Trans>Verification Email Sent</Trans>
|
||||
) : (
|
||||
<Trans>Verify Now</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<Trans>Verify your email address</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox
|
||||
and click the link in the email to verify your account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
disabled={isButtonDisabled}
|
||||
loading={isPending}
|
||||
onClick={onResendConfirmationEmail}
|
||||
>
|
||||
{isPending ? <Trans>Sending...</Trans> : <Trans>Resend Confirmation Email</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import { AppBanner } from '~/components/general/app-banner';
|
||||
import { Header } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
@@ -109,8 +108,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
return (
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={currentTeam || null}>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
|
||||
{!hideHeader && <Header />}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
Building2Icon,
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { Building2Icon, GroupIcon, MailboxIcon, Settings2Icon, Users2Icon } from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { Link, NavLink, Outlet } from 'react-router';
|
||||
|
||||
@@ -76,11 +69,6 @@ export default function SettingsLayout() {
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
];
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@hanzo/esign-lib/client-only/providers/organisation';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@hanzo/esign-lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@hanzo/esign-lib/constants/organisations-translations';
|
||||
import {
|
||||
formatOrganisationCallbackUrl,
|
||||
formatOrganisationLoginUrl,
|
||||
} from '@hanzo/esign-lib/utils/organisation-authentication-portal';
|
||||
import { useZapMutation, useZapQuery } from '@hanzo/esign-trpc/zap/react';
|
||||
import { domainRegex } from '@hanzo/esign-trpc/server/organisation-router/create-organisation-email-domain.types';
|
||||
import type { TGetOrganisationAuthenticationPortalResponse } from '@hanzo/esign-trpc/server/organisation-router/get-organisation-authentication-portal.types';
|
||||
import type { TUpdateOrganisationAuthenticationPortalRequest } from '@hanzo/esign-trpc/server/organisation-router/update-organisation-authentication-portal.types';
|
||||
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@hanzo/esign-trpc/server/organisation-router/update-organisation-authentication-portal.types';
|
||||
import { CopyTextButton } from '@hanzo/esign-ui/components/common/copy-text-button';
|
||||
import { Alert, AlertDescription } from '@hanzo/esign-ui/primitives/alert';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo/esign-ui/primitives/form/form';
|
||||
import { Input } from '@hanzo/esign-ui/primitives/input';
|
||||
import { Label } from '@hanzo/esign-ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@hanzo/esign-ui/primitives/select';
|
||||
import { SpinnerBox } from '@hanzo/esign-ui/primitives/spinner';
|
||||
import { Switch } from '@hanzo/esign-ui/primitives/switch';
|
||||
import { Textarea } from '@hanzo/esign-ui/primitives/textarea';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||
.pick({
|
||||
enabled: true,
|
||||
wellKnownUrl: true,
|
||||
clientId: true,
|
||||
autoProvisionUsers: true,
|
||||
defaultOrganisationRole: true,
|
||||
})
|
||||
.extend({
|
||||
clientSecret: z.string().nullable(),
|
||||
allowedDomains: z.string().refine(
|
||||
(value) => {
|
||||
const domains = value.split(' ').filter(Boolean);
|
||||
|
||||
return domains.every((domain) => domainRegex.test(domain));
|
||||
},
|
||||
{
|
||||
message: msg`Invalid domains`.id,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Organisation SSO Portal');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingSSOLoginPage() {
|
||||
const { t } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||
useZapQuery<TGetOrganisationAuthenticationPortalResponse>(
|
||||
'organisation.authenticationPortal.get',
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SSOProviderForm authenticationPortal={authenticationPortal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SSOProviderFormProps = {
|
||||
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
|
||||
};
|
||||
|
||||
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: updateOrganisationAuthenticationPortal } = useZapMutation<
|
||||
void,
|
||||
TUpdateOrganisationAuthenticationPortalRequest
|
||||
>('organisation.authenticationPortal.update');
|
||||
|
||||
const form = useForm<TProviderFormSchema>({
|
||||
resolver: zodResolver(ZProviderFormSchema),
|
||||
defaultValues: {
|
||||
enabled: authenticationPortal.enabled,
|
||||
clientId: authenticationPortal.clientId,
|
||||
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
|
||||
wellKnownUrl: authenticationPortal.wellKnownUrl,
|
||||
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
|
||||
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
|
||||
allowedDomains: authenticationPortal.allowedDomains.join(' '),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: TProviderFormSchema) => {
|
||||
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
|
||||
|
||||
if (enabled && !clientId) {
|
||||
form.setError('clientId', {
|
||||
message: t`Client ID is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && clientSecret === '') {
|
||||
form.setError('clientSecret', {
|
||||
message: t`Client secret is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !wellKnownUrl) {
|
||||
form.setError('wellKnownUrl', {
|
||||
message: t`Well-known URL is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOrganisationAuthenticationPortal({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: values.clientSecret ?? undefined,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers: values.autoProvisionUsers,
|
||||
defaultOrganisationRole: values.defaultOrganisationRole,
|
||||
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Provider has been updated successfully`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We couldn't update the provider. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSsoEnabled = form.watch('enabled');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Organisation authentication portal URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pr-12"
|
||||
disabled
|
||||
value={formatOrganisationLoginUrl(organisation.url)}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={formatOrganisationLoginUrl(organisation.url)}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Redirect URI</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pr-12"
|
||||
disabled
|
||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Required scopes</Trans>
|
||||
</Label>
|
||||
|
||||
<Input className="pr-12" disabled value={`openid profile email`} />
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>This is the required scopes you must set in your provider's settings</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wellKnownUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Issuer URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!form.formState.errors.wellKnownUrl && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Client ID</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="client-id" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Client Secret</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
{...field}
|
||||
value={field.value === null ? '**********************' : field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultOrganisationRole"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Organisation Role for New Users</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select default role`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
|
||||
(role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowedDomains"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Allowed Email Domains</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t`your-domain.com another-domain.com`}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!form.formState.errors.allowedDomains && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Space-separated list of domains. Leave empty to allow all domains.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="autoProvisionUsers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
<Trans>Auto-provision Users</Trans>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Automatically create accounts for new users on first login</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
<Trans>Enable SSO portal</Trans>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Whether to enable the SSO portal for your organisation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that anyone who signs in through your portal will be added to your
|
||||
organisation as a member.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button loading={form.formState.isSubmitting} type="submit">
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -3,61 +3,21 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
|
||||
import { useSession } from '@hanzo/esign-lib/client-only/providers/session';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@hanzo/esign-ui/primitives/alert';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
|
||||
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/security._index';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Security');
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
// Todo: Use providers instead after RR7 migration.
|
||||
// const accounts = await prisma.account.findMany({
|
||||
// where: {
|
||||
// userId: user.id,
|
||||
// },
|
||||
// select: {
|
||||
// provider: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const providers = accounts.map((account) => account.provider);
|
||||
// let hasEmailPasswordAccount = providers.includes('HANZO_SIGN');
|
||||
|
||||
const hasEmailPasswordAccount: boolean = await prisma.user
|
||||
.count({
|
||||
where: {
|
||||
id: user.id,
|
||||
password: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((value) => value > 0);
|
||||
|
||||
return {
|
||||
// providers,
|
||||
hasEmailPasswordAccount,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
const { hasEmailPasswordAccount } = loaderData;
|
||||
|
||||
export default function SettingsSecurity() {
|
||||
const { _ } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
||||
@@ -65,16 +25,8 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Security`)}
|
||||
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||
subtitle={_(msg`Here you can manage your security settings. You sign in with Hanzo.`)}
|
||||
/>
|
||||
{hasEmailPasswordAccount && (
|
||||
<>
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="mt-6 border-border/50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
@@ -86,17 +38,10 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
{hasEmailPasswordAccount ? (
|
||||
<Trans>
|
||||
Add an authenticator to serve as a secondary authentication method when signing
|
||||
in, or when signing documents.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Add an authenticator to serve as a secondary authentication method for signing
|
||||
documents.
|
||||
</Trans>
|
||||
)}
|
||||
<Trans>
|
||||
Add an authenticator to serve as a secondary authentication method for signing
|
||||
documents.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +85,8 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
<Trans>
|
||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||
Confirms it is you when signing documents, using biometrics, password managers,
|
||||
hardware keys, etc.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, Outlet, isRouteErrorResponse } from 'react-router';
|
||||
|
||||
@@ -63,30 +62,6 @@ export default function PublicProfileLayout() {
|
||||
className="h-10 w-auto sm:hidden dark:invert"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<p className="mr-4 text-muted-foreground">
|
||||
<span className="text-sm sm:hidden">
|
||||
<Trans>Want your own public profile?</Trans>
|
||||
</span>
|
||||
<span className="hidden text-sm sm:block">
|
||||
<Trans>Like to have your own public profile with agreements?</Trans>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<Button asChild variant="secondary">
|
||||
<Link to="/signup">
|
||||
<div className="hidden flex-row items-center sm:flex">
|
||||
<PlusIcon className="mr-1 h-5 w-5" />
|
||||
<Trans>Create now</Trans>
|
||||
</div>
|
||||
|
||||
<span className="sm:hidden">
|
||||
<Trans>Create</Trans>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
const user = sessionData?.user;
|
||||
|
||||
if (!data.isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||
return <DocumentSigningAuthPageView email={''} />;
|
||||
}
|
||||
|
||||
const { envelope, recipient } = data.envelopeForSigning;
|
||||
|
||||
@@ -321,12 +321,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
const user = sessionData?.user;
|
||||
|
||||
if (!data.isDocumentAccessValid) {
|
||||
return (
|
||||
<DocumentSigningAuthPageView
|
||||
email={data.recipientEmail}
|
||||
emailHasAccount={!!data.recipientHasAccount}
|
||||
/>
|
||||
);
|
||||
return <DocumentSigningAuthPageView email={data.recipientEmail} />;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -428,12 +423,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
const user = sessionData?.user;
|
||||
|
||||
if (!data.isDocumentAccessValid) {
|
||||
return (
|
||||
<DocumentSigningAuthPageView
|
||||
email={data.recipientEmail}
|
||||
emailHasAccount={!!data.recipientHasAccount}
|
||||
/>
|
||||
);
|
||||
return <DocumentSigningAuthPageView email={data.recipientEmail} />;
|
||||
}
|
||||
|
||||
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Forgot password');
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Email sent!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
<Trans>
|
||||
A password reset email has been sent, if you have an account you should see it in your
|
||||
inbox shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/signin">
|
||||
<Trans>Return to sign in</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Forgot Password');
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-3xl font-semibold">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
No worries, it happens! Enter your email and we'll email you a special link to reset
|
||||
your password.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ForgotPasswordForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
<Trans>
|
||||
Remembered your password?{' '}
|
||||
<Link to="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign In
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { MailsIcon } from 'lucide-react';
|
||||
import { Link, redirect, useSearchParams } from 'react-router';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import { Checkbox } from '@hanzo/esign-ui/primitives/checkbox';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.signin';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Sign In');
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
heading: msg`Authentication Portal Not Found`,
|
||||
subHeading: msg`404 Not Found`,
|
||||
message: msg`The organisation authentication portal does not exist, or is not configured`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { isAuthenticated, user } = await getOptionalSession(request);
|
||||
|
||||
const orgUrl = params.orgUrl;
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
url: orgUrl,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!organisation ||
|
||||
!organisation.organisationAuthenticationPortal.enabled ||
|
||||
!organisation.organisationClaim.flags.authenticationPortal
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to organisation if already signed in and a member of the organisation.
|
||||
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
|
||||
throw redirect(`/o/${orgUrl}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organisationName: organisation.name,
|
||||
orgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { organisationName, orgUrl } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const action = searchParams.get('action');
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await authClient.oidc.org.signIn({
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
if (action === 'verification-required') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<MailsIcon className="h-10 w-10 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Confirmation email sent</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-x-2">
|
||||
<Button asChild>
|
||||
<Link to={`/o/${orgUrl}/signin`} replace>
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Trans>Welcome to {organisationName}</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`flag-3rd-party-service`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={`flag-3rd-party-service`}
|
||||
>
|
||||
<Trans>
|
||||
I understand that I am providing my credentials to a 3rd party service configured by
|
||||
this organisation
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full bg-background"
|
||||
loading={isSubmitting}
|
||||
disabled={!isConfirmationChecked}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<Trans>Sign In</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
<Trans>OR</Trans>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
<Link to="/signin">
|
||||
<Trans>Return to Hanzo Sign sign in page here</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
|
||||
import { acceptOrganisationInvitation } from '@hanzo/esign-lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { isInvitedPerson } from '@hanzo/esign-lib/server-only/organisation/is-invited-person';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
|
||||
@@ -32,43 +34,45 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
if (
|
||||
!organisationMemberInvite ||
|
||||
organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED
|
||||
) {
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
// Emails are stored lowercased (see create-user.ts); SQLite has no
|
||||
// case-insensitive filter `mode`, so normalise the invite email instead.
|
||||
email: organisationMemberInvite.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const signInPath = `/signin?returnTo=${encodeURIComponent(`/organisation/invite/${token}`)}`;
|
||||
|
||||
// Directly convert the team member invite to a team member if they already have an account.
|
||||
if (user) {
|
||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// The invitation joins the person signed in, so nobody is added until the
|
||||
// invited person is the one holding the session.
|
||||
if (!session.user) {
|
||||
return {
|
||||
state: 'LoginRequired',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
signInPath,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!isInvitedPerson(session.user, organisationMemberInvite.email)) {
|
||||
return {
|
||||
state: 'WrongAccount',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
await acceptOrganisationInvitation({
|
||||
token: organisationMemberInvite.token,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
isSessionUserTheInvitedUser,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -83,7 +87,7 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
<Trans>Invalid token</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||
</Trans>
|
||||
@@ -106,20 +110,45 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You have been invited by <strong>{data.organisationName}</strong> to join their
|
||||
organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
<p className="mb-4 mt-1 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Sign in with Hanzo as <strong>{data.email}</strong> to accept this invitation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
<Link to={data.signInPath}>
|
||||
<Trans>Sign in to accept</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'WrongAccount') {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
This invitation from <strong>{data.organisationName}</strong> was sent to{' '}
|
||||
<strong>{data.email}</strong>. Sign in as that address to accept it.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -132,26 +161,18 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
<Trans>Invitation accepted!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join
|
||||
their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{data.isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Continue to login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||
import { data, isRouteErrorResponse } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@hanzo/esign-lib/constants/organisations';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@hanzo/esign-lib/types/organisation';
|
||||
import { formatAvatarUrl } from '@hanzo/esign-lib/utils/avatars';
|
||||
import { formatOrganisationLoginPath } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
|
||||
import { extractInitials } from '@hanzo/esign-lib/utils/recipient-formatter';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
import type { TDeclineLinkOrganisationAccountRequest } from '@hanzo/esign-trpc/server/organisation-router/decline-link-organisation-account.types';
|
||||
import type { TLinkOrganisationAccountRequest } from '@hanzo/esign-trpc/server/organisation-router/link-organisation-account.types';
|
||||
import { useZapMutation } from '@hanzo/esign-trpc/zap/react';
|
||||
import { Alert, AlertDescription } from '@hanzo/esign-ui/primitives/alert';
|
||||
import { AvatarWithText } from '@hanzo/esign-ui/primitives/avatar';
|
||||
import { Badge } from '@hanzo/esign-ui/primitives/badge';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@hanzo/esign-ui/primitives/card';
|
||||
import { Checkbox } from '@hanzo/esign-ui/primitives/checkbox';
|
||||
import { Separator } from '@hanzo/esign-ui/primitives/separator';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
|
||||
|
||||
import type { Route } from './+types/organisation.sso.confirmation.$token';
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
|
||||
|
||||
const errorMap = match(errorCode)
|
||||
.with('invalid-token', () => ({
|
||||
subHeading: msg`400 Error`,
|
||||
heading: msg`Invalid Token`,
|
||||
message: msg`The token is invalid or has expired.`,
|
||||
}))
|
||||
.otherwise(() => defaultErrorCodeMap[500]);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
|
||||
|
||||
if (!metadata.success) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: metadata.data.organisationId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
type: metadata.data.type,
|
||||
user: {
|
||||
name: verificationToken.user.name,
|
||||
email: verificationToken.user.email,
|
||||
avatar: verificationToken.user.avatarImageId,
|
||||
},
|
||||
organisation: {
|
||||
name: organisation.name,
|
||||
url: organisation.url,
|
||||
avatar: organisation.avatarImageId,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token, type, user, organisation } = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
|
||||
useZapMutation<void, TDeclineLinkOrganisationAccountRequest>(
|
||||
'organisation.authenticationPortal.declineLinkAccount',
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await navigate('/');
|
||||
|
||||
toast({
|
||||
title: _(msg`Account link declined`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: _(msg`Error declining account link`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: linkOrganisationAccount, isPending: isLinking } =
|
||||
useZapMutation<void, TLinkOrganisationAccountRequest>(
|
||||
'organisation.authenticationPortal.linkAccount',
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||
|
||||
toast({
|
||||
title: _(msg`Account linked successfully`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: _(msg`Error linking account`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-2xl border">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{type === 'link' ? (
|
||||
<Trans>Account Linking Request</Trans>
|
||||
) : (
|
||||
<Trans>Account Creation Request</Trans>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{type === 'link' ? (
|
||||
<Trans>
|
||||
An organisation wants to link your account. Please review the details below.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An organisation wants to create an account for you. Please review the details below.
|
||||
</Trans>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Current User Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
|
||||
<UserCircle2 className="h-4 w-4" />
|
||||
<Trans>Your Account</Trans>
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg bg-muted/50 p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatar)}
|
||||
avatarFallback={extractInitials(user.name || user.email)}
|
||||
primaryText={user.name}
|
||||
secondaryText={user.email}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Account</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Organisation Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<Trans>Requesting Organisation</Trans>
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg bg-muted/50 p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(organisation.avatar)}
|
||||
avatarFallback={extractInitials(organisation.name)}
|
||||
primaryText={organisation.name}
|
||||
secondaryText={`/o/${organisation.url}`}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Organisation</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Warnings Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 font-semibold text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Trans>Important: What This Means</Trans>
|
||||
</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>
|
||||
By accepting this request, you grant {organisation.name} the following
|
||||
permissions:
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Full account access:
|
||||
</span>{' '}
|
||||
View all your profile information, settings, and activity
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Account management:
|
||||
</span>{' '}
|
||||
Modify your account settings, permissions, and preferences
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="font-semibold text-muted-foreground">Data access:</span>{' '}
|
||||
Access all data associated with your account
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Alert variant="warning" className="mt-3">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This organisation will have administrative control over your account. You can
|
||||
revoke this access later, but they will retain access to any data they've
|
||||
already collected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`accept-conditions`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={`accept-conditions`}
|
||||
>
|
||||
<Trans>I agree to link my account with this organization</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDeclining || isLinking}
|
||||
onClick={() => declineLinkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!isConfirmationChecked || isDeclining || isLinking}
|
||||
loading={isLinking}
|
||||
onClick={() => linkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Accept & Link Account</Trans>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getResetTokenValidity } from '@hanzo/esign-lib/server-only/user/get-reset-token-validity';
|
||||
|
||||
import { ResetPasswordForm } from '~/components/forms/reset-password';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/reset-password.$token';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Reset Password');
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
const isValid = await getResetTokenValidity({ token });
|
||||
|
||||
if (!isValid) {
|
||||
throw redirect('/reset-password');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Reset Password</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please choose your new password</Trans>
|
||||
</p>
|
||||
|
||||
<ResetPasswordForm token={token} className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
<Trans>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Reset Password');
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-3xl font-semibold">
|
||||
<Trans>Unable to reset password</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
The token you have used to reset your password is either expired or it never existed. If
|
||||
you have still forgotten your password, please request a new reset link.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/signin">
|
||||
<Trans>Return to sign in</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@hanzo/esign-auth/server/lib/utils/get-session';
|
||||
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@hanzo/esign-lib/utils/is-valid-return-to';
|
||||
|
||||
import { brand } from '~/components/branding/brand';
|
||||
@@ -16,10 +17,14 @@ export function meta() {
|
||||
return appMetaTags('Sign In');
|
||||
}
|
||||
|
||||
const REFUSALS = new Set<string>(Object.values(SignInRefusal));
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { isAuthenticated } = await getOptionalSession(request);
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let returnTo = searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
@@ -27,13 +32,43 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
throw redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
const error = searchParams.get('error') ?? '';
|
||||
|
||||
return {
|
||||
returnTo,
|
||||
refusal: REFUSALS.has(error) ? (error as SignInRefusal) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const Refusal = ({ refusal }: { refusal: SignInRefusal }) => {
|
||||
switch (refusal) {
|
||||
case SignInRefusal.NotInvited:
|
||||
return (
|
||||
<Trans>
|
||||
Hanzo Sign is open to invited people only. Ask an admin to invite you, then sign in again.
|
||||
</Trans>
|
||||
);
|
||||
case SignInRefusal.UnverifiedEmail:
|
||||
return (
|
||||
<Trans>
|
||||
Your Hanzo account's email address is not verified. Verify it at Hanzo, then sign in
|
||||
again.
|
||||
</Trans>
|
||||
);
|
||||
case SignInRefusal.UnlinkedAccount:
|
||||
return (
|
||||
<Trans>
|
||||
An account with this email address exists but was never linked to your Hanzo sign-in. Ask
|
||||
an admin to link it.
|
||||
</Trans>
|
||||
);
|
||||
case SignInRefusal.Disabled:
|
||||
return <Trans>This account has been disabled. Ask an admin to restore it.</Trans>;
|
||||
}
|
||||
};
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { returnTo } = loaderData;
|
||||
const { returnTo, refusal } = loaderData;
|
||||
|
||||
const { primary, suffix } = brand();
|
||||
|
||||
@@ -54,6 +89,12 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
descriptionClassName="text-zinc-400"
|
||||
/>
|
||||
|
||||
{refusal && (
|
||||
<p role="alert" className="mt-6 text-sm text-red-400">
|
||||
<Refusal refusal={refusal} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<SignInForm returnTo={returnTo} />
|
||||
</div>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@hanzo/esign-lib/utils/is-valid-return-to';
|
||||
|
||||
import type { Route } from './+types/signup';
|
||||
|
||||
/**
|
||||
* Accounts are created at the identity provider, so /signup leads to the same
|
||||
* door as /signin. The provider offers account creation on that page.
|
||||
*
|
||||
* This used to render a local name/email/password form, which produced an
|
||||
* account /signin could not then authenticate: sign-in offers the provider and
|
||||
* nothing else, so a locally created password had nowhere to be typed.
|
||||
*/
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
const returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
const target = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
throw redirect(target ? `/signin?returnTo=${encodeURIComponent(target)}` : '/signin');
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Mails } from 'lucide-react';
|
||||
|
||||
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
|
||||
|
||||
export default function UnverifiedAccount() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Confirm email</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>
|
||||
If you don't find the confirmation link in your inbox, you can request a new one
|
||||
below.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<SendConfirmationEmailForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, CheckCircle2, Loader, XCircle } from 'lucide-react';
|
||||
import { Link, redirect, useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { authClient } from '@hanzo/esign-auth/client';
|
||||
import { useOptionalSession } from '@hanzo/esign-lib/client-only/providers/session';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@hanzo/esign-lib/constants/email';
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
|
||||
|
||||
import type { Route } from './+types/verify-email.$token';
|
||||
|
||||
export const loader = ({ params }: Route.LoaderArgs) => {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw redirect('/verify-email');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
};
|
||||
};
|
||||
|
||||
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token } = loaderData;
|
||||
|
||||
const { refreshSession } = useOptionalSession();
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const verifyToken = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await authClient.emailPassword.verifyEmail({
|
||||
token,
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
|
||||
setState(response.state);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to verify your email at this time.`),
|
||||
});
|
||||
|
||||
await navigate('/verify-email');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void verifyToken();
|
||||
}, []);
|
||||
|
||||
if (isLoading || state === null) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Loader className="h-8 w-8 animate-spin text-sign" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return match(state)
|
||||
.with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<AlertTriangle className="h-10 w-10 text-muted-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Something went wrong</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>
|
||||
We were unable to verify your email. If your email is not verified already, please
|
||||
try again.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Go back home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="h-10 w-10 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Your token has expired!</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>
|
||||
It seems that the provided token has expired. We've just sent you another token,
|
||||
please check your email and try again.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Go back home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.with(EMAIL_VERIFICATION_STATE.VERIFIED, () => (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<CheckCircle2 className="h-10 w-10 text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Email Confirmed!</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>
|
||||
Your email has been successfully confirmed! You can now use all features of Hanzo
|
||||
Sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<CheckCircle2 className="h-10 w-10 text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Email already confirmed</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>
|
||||
Your email has already been confirmed. You can now use all features of Hanzo Sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Go back home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { Button } from '@hanzo/esign-ui/primitives/button';
|
||||
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Verify Email');
|
||||
}
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Uh oh! Looks like you're missing a token</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>
|
||||
It seems that there is no token provided, if you are trying to verify your email
|
||||
please follow the link in your email.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Go back home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,8 +209,6 @@ Here's a markdown table documenting all the provided environment variables:
|
||||
| `NEXT_PRIVATE_AUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `BASE_PATH` | Root directory for per-tenant SQLite (Hanzo Base). Each org gets `${BASE_PATH}/${orgId}/sign.db`. |
|
||||
| `DATABASE_URL` | SQLite file URL for the default (CLI / migration) database, e.g. `file:${BASE_PATH}/_dev/sign.db`. |
|
||||
|
||||
@@ -11,8 +11,6 @@ services:
|
||||
- NEXT_PRIVATE_AUTH_SECRET=${NEXT_PRIVATE_AUTH_SECRET:?err}
|
||||
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY:?err}
|
||||
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY:?err}
|
||||
- NEXT_PRIVATE_GOOGLE_CLIENT_ID=${NEXT_PRIVATE_GOOGLE_CLIENT_ID}
|
||||
- NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=${NEXT_PRIVATE_GOOGLE_CLIENT_SECRET}
|
||||
- NEXT_PUBLIC_WEBAPP_URL=${NEXT_PUBLIC_WEBAPP_URL:?err}
|
||||
- NEXT_PRIVATE_INTERNAL_WEBAPP_URL=${NEXT_PRIVATE_INTERNAL_WEBAPP_URL:-http://localhost:$PORT}
|
||||
- BASE_PATH=${BASE_PATH:-/opt/hanzo-sign/base/data}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
|
||||
import { formatSecureCookieName } from '@hanzo/esign-lib/constants/auth';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
type LoginOptions = {
|
||||
page: Page;
|
||||
email?: string;
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* Where to navigate after login.
|
||||
@@ -13,24 +15,58 @@ type LoginOptions = {
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Signs the page in as the seeded user with this email.
|
||||
*
|
||||
* The app signs people in through Hanzo IAM only, and a test cannot drive IAM.
|
||||
* So the suite starts the session the way a completed IAM sign-in leaves it: a
|
||||
* Session row for the user and the signed `sessionId` cookie that names it,
|
||||
* signed with the app's own NEXT_PRIVATE_AUTH_SECRET exactly as hono signs it.
|
||||
*/
|
||||
export const apiSignin = async ({
|
||||
page,
|
||||
email = '[email protected]',
|
||||
password = 'password',
|
||||
redirectPath = '/',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
|
||||
const csrfToken = await getCsrfToken(page);
|
||||
|
||||
await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/email-password/authorize`, {
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
csrfToken,
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const token = randomBytes(20).toString('hex');
|
||||
const id = createHash('sha256').update(token).digest('hex');
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
id,
|
||||
sessionToken: id,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
|
||||
},
|
||||
});
|
||||
|
||||
const secret = process.env.NEXT_PRIVATE_AUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('NEXT_PRIVATE_AUTH_SECRET is not set');
|
||||
}
|
||||
|
||||
const signature = createHmac('sha256', secret).update(token).digest('base64');
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: formatSecureCookieName('sessionId'),
|
||||
value: encodeURIComponent(`${token}.${signature}`),
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${redirectPath}`);
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
@@ -43,22 +79,6 @@ export const apiSignout = async ({ page }: { page: Page }) => {
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
|
||||
};
|
||||
|
||||
const getCsrfToken = async (page: Page) => {
|
||||
const { request } = page.context();
|
||||
|
||||
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/csrf`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
if (!csrfToken) {
|
||||
throw new Error('Invalid session');
|
||||
}
|
||||
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
export const checkSessionValid = async (page: Page): Promise<boolean> => {
|
||||
const { request } = page.context();
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: 'password',
|
||||
redirectPath: '/settings/security/sessions',
|
||||
});
|
||||
|
||||
@@ -24,7 +23,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: 'password',
|
||||
redirectPath: '/settings/security/sessions',
|
||||
});
|
||||
|
||||
@@ -33,7 +31,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: 'password',
|
||||
redirectPath: '/settings/security/sessions',
|
||||
});
|
||||
|
||||
@@ -54,7 +51,6 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: 'password',
|
||||
redirectPath: '/settings/security/sessions',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
import { seedUser } from '@hanzo/esign-prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
|
||||
// Assert that password cannot be same as old password.
|
||||
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
|
||||
await page.getByLabel('Repeat Password').fill(oldPassword);
|
||||
|
||||
// Ensure both fields are filled before clicking
|
||||
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(oldPassword);
|
||||
await expect(page.getByLabel('Repeat Password')).toHaveValue(oldPassword);
|
||||
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText(
|
||||
'Your new password cannot be the same as your old password.',
|
||||
);
|
||||
|
||||
// Assert password reset.
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Repeat Password').fill(newPassword);
|
||||
|
||||
// Ensure both fields are filled before clicking
|
||||
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(newPassword);
|
||||
await expect(page.getByLabel('Repeat Password')).toHaveValue(newPassword);
|
||||
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
|
||||
|
||||
// Assert sign in works.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
|
||||
test('[USER] can reset password via user settings', async ({ page }: { page: Page }) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/security',
|
||||
});
|
||||
|
||||
await page.getByLabel('Current password').fill(oldPassword);
|
||||
await page.getByLabel('New password').fill(newPassword);
|
||||
await page.getByLabel('Repeat password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Password updated');
|
||||
|
||||
await apiSignout({
|
||||
page,
|
||||
});
|
||||
|
||||
// Assert sign in works.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
|
||||
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Repeat Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
|
||||
|
||||
await page.context().addCookies(initialCookies);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
test('[USER] password update invalidates other sessions but keeps current', async ({
|
||||
page,
|
||||
}: {
|
||||
page: Page;
|
||||
}) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await page.getByLabel('Current password').fill(oldPassword);
|
||||
await page.getByLabel('New password').fill(newPassword);
|
||||
await page.getByLabel('Repeat password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Password updated');
|
||||
|
||||
const finalCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(initialCookies);
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(finalCookies);
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await expect(page).toHaveURL('http://localhost:3000/settings/security');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Who a Hanzo IAM sign-in may make an account for.
|
||||
*
|
||||
* The callback reads IAM's userinfo answer and makes an account on a first
|
||||
* sign-in only for someone who was asked in. One half of that is read here: the
|
||||
* person administers the org the application serves. `isAdmin` is IAM's "admin
|
||||
* of the account's own org", so it must count only when that org is the
|
||||
* application's — an admin of a personal org of one is not an admin of Hanzo.
|
||||
*
|
||||
* Run: npx tsx --test packages/auth/__tests__/iam-person.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
import { administersAppOrg, readIamPerson } from '../server/lib/utils/iam-person';
|
||||
|
||||
const userinfo = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
sub: 'b474eaa5-e000-474d-b317-c37bbd9a6945',
|
||||
email: '[email protected]',
|
||||
email_verified: true,
|
||||
name: 'a',
|
||||
displayName: 'A',
|
||||
owner: 'hanzo',
|
||||
isAdmin: true,
|
||||
orgs: [{ org: 'hanzo', role: 'admin' }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('readIamPerson', () => {
|
||||
test('reads the person IAM described', () => {
|
||||
const person = readIamPerson(userinfo(), 'b474eaa5-e000-474d-b317-c37bbd9a6945');
|
||||
|
||||
assert.equal(person.email, '[email protected]');
|
||||
assert.equal(person.emailVerified, true);
|
||||
assert.equal(person.name, 'A');
|
||||
assert.equal(person.owner, 'hanzo');
|
||||
assert.deepEqual(person.orgs, [{ org: 'hanzo', role: 'admin' }]);
|
||||
});
|
||||
|
||||
test('refuses userinfo for a different subject than the id_token', () => {
|
||||
assert.throws(() => readIamPerson(userinfo(), 'someone-else'));
|
||||
});
|
||||
|
||||
test('refuses userinfo without an email', () => {
|
||||
assert.throws(() =>
|
||||
readIamPerson(userinfo({ email: undefined }), 'b474eaa5-e000-474d-b317-c37bbd9a6945'),
|
||||
);
|
||||
});
|
||||
|
||||
test('an address IAM has not proved is unverified, whatever shape the claim takes', () => {
|
||||
for (const email_verified of [false, 'true', 1, undefined, null]) {
|
||||
const person = readIamPerson(
|
||||
userinfo({ email_verified }),
|
||||
'b474eaa5-e000-474d-b317-c37bbd9a6945',
|
||||
);
|
||||
|
||||
assert.equal(person.emailVerified, false, String(email_verified));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('administersAppOrg', () => {
|
||||
const sub = 'b474eaa5-e000-474d-b317-c37bbd9a6945';
|
||||
|
||||
test('an admin whose account lives in the app org', () => {
|
||||
assert.equal(administersAppOrg(readIamPerson(userinfo(), sub)), true);
|
||||
});
|
||||
|
||||
test('an owner whose account lives in the app org', () => {
|
||||
const person = readIamPerson(userinfo({ orgs: [{ org: 'hanzo', role: 'owner' }] }), sub);
|
||||
|
||||
assert.equal(administersAppOrg(person), true);
|
||||
});
|
||||
|
||||
test('a member of the app org is not', () => {
|
||||
const person = readIamPerson(
|
||||
userinfo({ isAdmin: false, orgs: [{ org: 'hanzo', role: 'member' }] }),
|
||||
sub,
|
||||
);
|
||||
|
||||
assert.equal(administersAppOrg(person), false);
|
||||
});
|
||||
|
||||
test('the admin of a personal org is not, even with a membership in the app org', () => {
|
||||
const person = readIamPerson(
|
||||
userinfo({
|
||||
orgs: [
|
||||
{ org: 'gilbert-mooy2', role: 'admin' },
|
||||
{ org: 'hanzo', role: 'admin' },
|
||||
],
|
||||
}),
|
||||
sub,
|
||||
);
|
||||
|
||||
assert.equal(administersAppOrg(person), false);
|
||||
});
|
||||
|
||||
test('isAdmin without the admin role in the app org is not', () => {
|
||||
const person = readIamPerson(userinfo({ orgs: [{ org: 'hanzo', role: 'member' }] }), sub);
|
||||
|
||||
assert.equal(administersAppOrg(person), false);
|
||||
});
|
||||
|
||||
test('no owner claim is not', () => {
|
||||
const person = readIamPerson(userinfo({ owner: undefined }), sub);
|
||||
|
||||
assert.equal(administersAppOrg(person), false);
|
||||
});
|
||||
|
||||
test('no orgs is not', () => {
|
||||
const person = readIamPerson(userinfo({ orgs: undefined }), sub);
|
||||
|
||||
assert.equal(administersAppOrg(person), false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClientResponse, InferRequestType } from 'hono/client';
|
||||
import type { ClientResponse } from 'hono/client';
|
||||
import { hc } from 'hono/client';
|
||||
import superjson from 'superjson';
|
||||
|
||||
@@ -9,31 +9,14 @@ import type { AuthAppType } from '../server';
|
||||
import type { SessionValidationResult } from '../server/lib/session/session';
|
||||
import type { PartialAccount } from '../server/lib/utils/get-accounts';
|
||||
import type { ActiveSession } from '../server/lib/utils/get-session';
|
||||
import { handleSignInRedirect } from '../server/lib/utils/redirect';
|
||||
import type {
|
||||
TDisableTwoFactorRequestSchema,
|
||||
TEnableTwoFactorRequestSchema,
|
||||
TViewTwoFactorRecoveryCodesRequestSchema,
|
||||
} from '../server/routes/two-factor.types';
|
||||
import type {
|
||||
TForgotPasswordSchema,
|
||||
TResendVerifyEmailSchema,
|
||||
TResetPasswordSchema,
|
||||
TSignUpSchema,
|
||||
TUpdatePasswordSchema,
|
||||
TVerifyEmailSchema,
|
||||
} from '../server/types/email-password';
|
||||
|
||||
type AuthClientType = ReturnType<typeof hc<AuthAppType>>;
|
||||
|
||||
type TEmailPasswordSignin = InferRequestType<
|
||||
AuthClientType['email-password']['authorize']['$post']
|
||||
>['json'] & { redirectPath?: string };
|
||||
|
||||
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
|
||||
@@ -134,95 +117,6 @@ export class AuthClient {
|
||||
},
|
||||
};
|
||||
|
||||
public emailPassword = {
|
||||
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
|
||||
let csrfToken = data.csrfToken;
|
||||
|
||||
if (!csrfToken) {
|
||||
csrfToken = (await this.client.csrf.$get().then(async (res) => res.json())).csrfToken;
|
||||
}
|
||||
|
||||
const response = await this.client['email-password'].authorize.$post({
|
||||
json: {
|
||||
...data,
|
||||
csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
|
||||
updatePassword: async (data: TUpdatePasswordSchema) => {
|
||||
const response = await this.client['email-password']['update-password'].$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
forgotPassword: async (data: TForgotPasswordSchema) => {
|
||||
const response = await this.client['email-password']['forgot-password'].$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resetPassword: async (data: TResetPasswordSchema) => {
|
||||
const response = await this.client['email-password']['reset-password'].$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
signUp: async (data: TSignUpSchema) => {
|
||||
const response = await this.client['email-password']['signup'].$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resendVerifyEmail: async (data: TResendVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['resend-verify-email'].$post({
|
||||
json: data,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
verifyEmail: async (data: TVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['verify-email'].$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
public twoFactor = {
|
||||
setup: async () => {
|
||||
const response = await this.client['two-factor'].setup.$post();
|
||||
@@ -268,61 +162,6 @@ export class AuthClient {
|
||||
},
|
||||
};
|
||||
|
||||
public passkey = {
|
||||
signIn: async (data: TPasskeySignin) => {
|
||||
const response = await this.client['passkey'].authorize.$post({ json: data });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
};
|
||||
|
||||
public google = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.google.$post({
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to external Google auth URL.
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public microsoft = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.microsoft.$post({
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public hanzo = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.hanzo.$post({
|
||||
@@ -342,45 +181,6 @@ export class AuthClient {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public oidc = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to external OIDC provider URL.
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
org: {
|
||||
signIn: async ({ orgUrl }: { orgUrl: string }) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
|
||||
param: { orgUrl },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to external OIDC provider URL.
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const authClient = new AuthClient({
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "tsx --test __tests__/*.test.ts",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,41 +13,14 @@ export type OAuthClientOptions = {
|
||||
clientSecret: string;
|
||||
wellKnownUrl: string;
|
||||
redirectUrl: string;
|
||||
bypassEmailVerification?: boolean;
|
||||
};
|
||||
|
||||
export const GoogleAuthOptions: OAuthClientOptions = {
|
||||
id: 'google',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') ?? '',
|
||||
clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET') ?? '',
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/google`,
|
||||
wellKnownUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
||||
bypassEmailVerification: false,
|
||||
};
|
||||
|
||||
export const MicrosoftAuthOptions: OAuthClientOptions = {
|
||||
id: 'microsoft',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
|
||||
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/microsoft`,
|
||||
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
|
||||
bypassEmailVerification: false,
|
||||
};
|
||||
|
||||
export const OidcAuthOptions: OAuthClientOptions = {
|
||||
id: 'oidc',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId: env('NEXT_PRIVATE_OIDC_CLIENT_ID') ?? '',
|
||||
clientSecret: env('NEXT_PRIVATE_OIDC_CLIENT_SECRET') ?? '',
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/oidc`,
|
||||
wellKnownUrl: env('NEXT_PRIVATE_OIDC_WELL_KNOWN') ?? '',
|
||||
bypassEmailVerification: env('NEXT_PRIVATE_OIDC_SKIP_VERIFY') === 'true',
|
||||
};
|
||||
|
||||
const iamUrl = env('IAM_URL') ?? 'https://hanzo.id';
|
||||
|
||||
/**
|
||||
* Hanzo IAM is the one way into the app. A person signs in at IAM, and the
|
||||
* account they arrive with is IAM's answer, read from its userinfo endpoint.
|
||||
*/
|
||||
export const HanzoAuthOptions: OAuthClientOptions = {
|
||||
id: 'hanzo',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
@@ -55,5 +28,4 @@ export const HanzoAuthOptions: OAuthClientOptions = {
|
||||
clientSecret: env('IAM_CLIENT_SECRET') ?? '',
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/auth/callback`,
|
||||
wellKnownUrl: `${iamUrl}/.well-known/openid-configuration`,
|
||||
bypassEmailVerification: true,
|
||||
};
|
||||
|
||||
@@ -10,10 +10,7 @@ import { HanzoAuthOptions } from './config';
|
||||
import { setCsrfCookie } from './lib/session/session-cookies';
|
||||
import { handleOAuthCallbackUrl } from './lib/utils/handle-oauth-callback-url';
|
||||
import { accountRoute } from './routes/account';
|
||||
import { callbackRoute } from './routes/callback';
|
||||
import { emailPasswordRoute } from './routes/email-password';
|
||||
import { oauthRoute } from './routes/oauth';
|
||||
import { passkeyRoute } from './routes/passkey';
|
||||
import { sessionRoute } from './routes/session';
|
||||
import { signOutRoute } from './routes/sign-out';
|
||||
import { twoFactorRoute } from './routes/two-factor';
|
||||
@@ -47,10 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
|
||||
.route('/', sessionRoute)
|
||||
.route('/', signOutRoute)
|
||||
.route('/', accountRoute)
|
||||
.route('/callback', callbackRoute)
|
||||
.route('/oauth', oauthRoute)
|
||||
.route('/email-password', emailPasswordRoute)
|
||||
.route('/passkey', passkeyRoute)
|
||||
.route('/two-factor', twoFactorRoute);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { OAuth2Client, decodeIdToken } from 'arctic';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie } from 'hono/cookie';
|
||||
|
||||
import { SignInRefusal } from '@hanzo/esign-lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@hanzo/esign-lib/server-only/user/create-user';
|
||||
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
|
||||
@@ -13,6 +14,7 @@ import { prisma } from '@hanzo/esign-prisma';
|
||||
import type { OAuthClientOptions } from '../../config';
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { administersAppOrg, readIamPerson } from './iam-person';
|
||||
import { getOpenIdConfiguration } from './open-id';
|
||||
|
||||
type HandleOAuthCallbackUrlOptions = {
|
||||
@@ -23,103 +25,88 @@ type HandleOAuthCallbackUrlOptions = {
|
||||
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const { c, clientOptions } = options;
|
||||
|
||||
const requestMeta = c.get('requestMetadata');
|
||||
const { person, accessToken, accessTokenExpiresAt, idToken, redirectPath } = await validateOauth({
|
||||
c,
|
||||
clientOptions,
|
||||
});
|
||||
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
|
||||
await validateOauth({ c, clientOptions });
|
||||
const refuse = (reason: SignInRefusal) => c.redirect(`/signin?error=${reason}`, 302);
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
) {
|
||||
const email = person.email.toLowerCase();
|
||||
|
||||
if (email === legacyServiceAccountEmail() || email === deletedServiceAccountEmail()) {
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
// Find the account if possible.
|
||||
if (!person.emailVerified) {
|
||||
return refuse(SignInRefusal.UnverifiedEmail);
|
||||
}
|
||||
|
||||
// An account is found by the IAM identity it is linked to, and by nothing else.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
where: {
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
providerAccountId: person.sub,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
if (existingAccount.user.disabled) {
|
||||
return refuse(SignInRefusal.Disabled);
|
||||
}
|
||||
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
}
|
||||
|
||||
// An address is not an identity: an account that holds this address but was
|
||||
// never linked to this IAM identity is not handed to whoever signs in with it.
|
||||
const userWithSameEmail = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle existing user but no account.
|
||||
if (userWithSameEmail) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: 'oauth',
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
access_token: accessToken,
|
||||
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
token_type: 'Bearer',
|
||||
id_token: idToken,
|
||||
userId: userWithSameEmail.id,
|
||||
},
|
||||
});
|
||||
return refuse(SignInRefusal.UnlinkedAccount);
|
||||
}
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: userWithSameEmail.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
|
||||
},
|
||||
});
|
||||
// A first sign-in makes an account only for someone who was asked in: an admin
|
||||
// of the org this application serves, or a person an organisation invited at
|
||||
// this verified address.
|
||||
const invited =
|
||||
administersAppOrg(person) ||
|
||||
(await prisma.organisationMemberInvite.findFirst({
|
||||
where: {
|
||||
email,
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})) !== null;
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in since we cannot confirm the password was set by the user.
|
||||
if (!userWithSameEmail.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: userWithSameEmail.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: userWithSameEmail.id }, c);
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
if (!invited) {
|
||||
return refuse(SignInRefusal.NotInvited);
|
||||
}
|
||||
|
||||
// Handle new user.
|
||||
const createdUser = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
name: name,
|
||||
email,
|
||||
name: person.name,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -128,7 +115,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
data: {
|
||||
type: 'oauth',
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
providerAccountId: person.sub,
|
||||
access_token: accessToken,
|
||||
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
token_type: 'Bearer',
|
||||
@@ -141,7 +128,6 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
});
|
||||
|
||||
await onCreateUserHook(createdUser).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
@@ -157,9 +143,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
|
||||
requiredScopes: clientOptions.scope,
|
||||
});
|
||||
const { token_endpoint, userinfo_endpoint } = await getOpenIdConfiguration(
|
||||
clientOptions.wellKnownUrl,
|
||||
{
|
||||
requiredScopes: clientOptions.scope,
|
||||
},
|
||||
);
|
||||
|
||||
const oAuthClient = new OAuth2Client(
|
||||
clientOptions.clientId,
|
||||
@@ -204,40 +193,30 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const idToken = tokens.idToken();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
|
||||
const claims = decodeIdToken(idToken) as Record<string, unknown>;
|
||||
|
||||
const email = claims.email;
|
||||
const name = claims.name;
|
||||
const sub = claims.sub;
|
||||
|
||||
if (typeof email !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing email',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof name !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing name',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof sub !== 'string') {
|
||||
if (typeof claims.sub !== 'string' || claims.sub === '') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing sub claim',
|
||||
});
|
||||
}
|
||||
|
||||
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
|
||||
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
|
||||
message: 'Account email is not verified',
|
||||
const response = await fetch(userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(AuthenticationErrorCode.Unauthorized, {
|
||||
message: `The identity provider refused the userinfo request: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const info = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
sub,
|
||||
person: readIamPerson(info, claims.sub),
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
idToken,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@hanzo/esign-lib/server-only/organisation/send-organisation-account-link-confirmation-email';
|
||||
import { AppError } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@hanzo/esign-lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { validateOauth } from './handle-oauth-callback-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
|
||||
|
||||
type HandleOAuthOrganisationCallbackUrlOptions = {
|
||||
c: Context;
|
||||
orgUrl: string;
|
||||
};
|
||||
|
||||
export const handleOAuthOrganisationCallbackUrl = async (
|
||||
options: HandleOAuthOrganisationCallbackUrlOptions,
|
||||
) => {
|
||||
const { c, orgUrl } = options;
|
||||
|
||||
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
|
||||
c,
|
||||
clientOptions: {
|
||||
...clientOptions,
|
||||
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
|
||||
},
|
||||
});
|
||||
|
||||
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
|
||||
|
||||
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Email domain not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
// Find the account if possible.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
where: {
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle new user.
|
||||
if (!userToLink) {
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
name: name,
|
||||
emailVerified: null, // Do not verify email.
|
||||
},
|
||||
});
|
||||
|
||||
await onCreateUserHook(userToLink).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
await sendOrganisationAccountLinkConfirmationEmail({
|
||||
type: userToLink.emailVerified ? 'link' : 'create',
|
||||
userId: userToLink.id,
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
oauthConfig: {
|
||||
accessToken,
|
||||
idToken,
|
||||
providerAccountId: sub,
|
||||
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { AppError } from '@hanzo/esign-lib/errors/app-error';
|
||||
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
|
||||
/**
|
||||
* The person IAM signed in, as its userinfo endpoint states them. Userinfo is
|
||||
* read rather than the id_token because it is IAM's live answer from its store:
|
||||
* `email_verified`, `isAdmin` and `orgs` are there and nowhere else, and a grant
|
||||
* IAM revoked a minute ago is already gone from it.
|
||||
*/
|
||||
export type IamPerson = {
|
||||
sub: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
/** The org the application belongs to; IAM stamps it on every sign-in. */
|
||||
owner: string;
|
||||
isAdmin: boolean;
|
||||
/** The orgs the person may act in, the one their account lives in first. */
|
||||
orgs: Array<{ org: string; role?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the person administers the org this application serves: their
|
||||
* account lives in that org and holds its admin role there. IAM's `isAdmin` is
|
||||
* admin of the account's OWN org, so it counts only when that org is the
|
||||
* application's.
|
||||
*/
|
||||
export const administersAppOrg = (person: IamPerson): boolean => {
|
||||
const home = person.orgs[0];
|
||||
|
||||
return (
|
||||
person.isAdmin &&
|
||||
Boolean(person.owner) &&
|
||||
home?.org === person.owner &&
|
||||
(home.role === 'admin' || home.role === 'owner')
|
||||
);
|
||||
};
|
||||
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
/**
|
||||
* Reads IAM's userinfo answer into an IamPerson, refusing one that does not
|
||||
* describe the identity the id_token was issued for.
|
||||
*/
|
||||
export const readIamPerson = (info: Record<string, unknown>, idTokenSub: string): IamPerson => {
|
||||
const sub = str(info.sub);
|
||||
|
||||
if (!sub || sub !== idTokenSub) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'The identity provider described a different account',
|
||||
});
|
||||
}
|
||||
|
||||
const email = str(info.email);
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing email',
|
||||
});
|
||||
}
|
||||
|
||||
const orgs = Array.isArray(info.orgs)
|
||||
? info.orgs
|
||||
.filter((o): o is Record<string, unknown> => Boolean(o) && typeof o === 'object')
|
||||
.map((o) => ({ org: str(o.org), role: str(o.role) }))
|
||||
.filter((o) => o.org !== '')
|
||||
: [];
|
||||
|
||||
return {
|
||||
sub,
|
||||
email,
|
||||
emailVerified: info.email_verified === true,
|
||||
name: str(info.displayName) || str(info.name) || email,
|
||||
owner: str(info.owner),
|
||||
isAdmin: info.isAdmin === true,
|
||||
orgs,
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
const ZOpenIdConfigurationSchema = z.object({
|
||||
authorization_endpoint: z.string(),
|
||||
token_endpoint: z.string(),
|
||||
userinfo_endpoint: z.string(),
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { SIGN_ENCRYPTION_KEY } from '@hanzo/esign-lib/constants/crypto';
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { symmetricDecrypt } from '@hanzo/esign-lib/universal/crypto';
|
||||
import { formatOrganisationCallbackUrl } from '@hanzo/esign-lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions =
|
||||
| {
|
||||
type: 'url';
|
||||
organisationUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'id';
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortalOptions = async (
|
||||
options: GetOrganisationAuthenticationPortalOptions,
|
||||
) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where:
|
||||
options.type === 'url'
|
||||
? {
|
||||
url: options.organisationUrl,
|
||||
}
|
||||
: {
|
||||
id: options.organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationAuthenticationPortal: true,
|
||||
groups: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.organisationAuthenticationPortal.enabled) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not enabled for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
} = organisation.organisationAuthenticationPortal;
|
||||
|
||||
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not configured for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
if (!SIGN_ENCRYPTION_KEY) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Encryption key is not set',
|
||||
});
|
||||
}
|
||||
|
||||
const clientSecret = Buffer.from(
|
||||
symmetricDecrypt({ key: SIGN_ENCRYPTION_KEY, data: encryptedClientSecret }),
|
||||
).toString('utf-8');
|
||||
|
||||
return {
|
||||
organisation,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
clientOptions: {
|
||||
id: organisation.id,
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
|
||||
wellKnownUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@hanzo/esign-lib/constants/app';
|
||||
|
||||
/**
|
||||
* Handle an optional redirect path.
|
||||
*/
|
||||
export const handleRequestRedirect = (redirectUrl?: string) => {
|
||||
if (!redirectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { AppError } from '@hanzo/esign-lib/errors/app-error';
|
||||
|
||||
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
||||
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
/**
|
||||
* Have to create this route instead of bundling callback with oauth routes to provide
|
||||
* backwards compatibility for self-hosters (legacy session-cookie name compatibility).
|
||||
*/
|
||||
export const callbackRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* OIDC callback verification.
|
||||
*/
|
||||
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
|
||||
|
||||
/**
|
||||
* Organisation OIDC callback verification.
|
||||
*/
|
||||
.get('/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
try {
|
||||
return await handleOAuthOrganisationCallbackUrl({
|
||||
c,
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
throw new AppError(err.name, {
|
||||
message: err.message,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Google callback verification.
|
||||
*/
|
||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
|
||||
|
||||
/**
|
||||
* Microsoft callback verification.
|
||||
*/
|
||||
.get('/microsoft', async (c) =>
|
||||
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
|
||||
);
|
||||
@@ -1,561 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EMAIL_VERIFICATION_STATE } from '@hanzo/esign-lib/constants/email';
|
||||
import { AppError } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { jobsClient } from '@hanzo/esign-lib/jobs/client';
|
||||
import { disableTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/disable-2fa';
|
||||
import { enableTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/enable-2fa';
|
||||
import { isTwoFactorAuthenticationEnabled } from '@hanzo/esign-lib/server-only/2fa/is-2fa-availble';
|
||||
import { setupTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/setup-2fa';
|
||||
import { validateTwoFactorAuthentication } from '@hanzo/esign-lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@hanzo/esign-lib/server-only/2fa/view-backup-codes';
|
||||
import { rateLimitResponse } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
forgotPasswordRateLimit,
|
||||
loginRateLimit,
|
||||
resendVerifyEmailRateLimit,
|
||||
resetPasswordRateLimit,
|
||||
signupRateLimit,
|
||||
verifyEmailRateLimit,
|
||||
} from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
|
||||
import { createUser } from '@hanzo/esign-lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@hanzo/esign-lib/server-only/user/forgot-password';
|
||||
import { getMostRecentEmailVerificationToken } from '@hanzo/esign-lib/server-only/user/get-most-recent-email-verification-token';
|
||||
import { getUserByResetToken } from '@hanzo/esign-lib/server-only/user/get-user-by-reset-token';
|
||||
import { resetPassword } from '@hanzo/esign-lib/server-only/user/reset-password';
|
||||
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import { updatePassword } from '@hanzo/esign-lib/server-only/user/update-password';
|
||||
import { verifyEmail } from '@hanzo/esign-lib/server-only/user/verify-email';
|
||||
import { env } from '@hanzo/esign-lib/utils/env';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
|
||||
import { invalidateSessions } from '../lib/session/session';
|
||||
import { getCsrfCookie } from '../lib/session/session-cookies';
|
||||
import { onAuthorize } from '../lib/utils/authorizer';
|
||||
import { getSession } from '../lib/utils/get-session';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
import {
|
||||
ZForgotPasswordSchema,
|
||||
ZResendVerifyEmailSchema,
|
||||
ZResetPasswordSchema,
|
||||
ZSignInSchema,
|
||||
ZSignUpSchema,
|
||||
ZUpdatePasswordSchema,
|
||||
ZVerifyEmailSchema,
|
||||
} from '../types/email-password';
|
||||
|
||||
export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* Authorize endpoint.
|
||||
*/
|
||||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const loginLimited = rateLimitResponse(c, loginLimitResult);
|
||||
|
||||
if (loginLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: loginLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const csrfCookieToken = await getCsrfCookie(c);
|
||||
|
||||
// Todo: (RR7) Add logging here.
|
||||
if (csrfToken !== csrfCookieToken || !csrfCookieToken) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Invalid CSRF token',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
) {
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
}
|
||||
|
||||
const isPasswordsSame = await compare(password, user.password);
|
||||
|
||||
if (!isPasswordsSame) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
}
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (
|
||||
!mostRecentToken ||
|
||||
mostRecentToken.expires.valueOf() <= Date.now() ||
|
||||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError('UNVERIFIED_EMAIL', {
|
||||
message: 'Unverified email',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new AppError('ACCOUNT_DISABLED', {
|
||||
message: 'Account disabled',
|
||||
});
|
||||
}
|
||||
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
return c.text('', 201);
|
||||
})
|
||||
/**
|
||||
* Signup endpoint.
|
||||
*/
|
||||
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
throw new AppError('SIGNUP_DISABLED', {
|
||||
message: 'Signups are disabled.',
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature } = c.req.valid('json');
|
||||
|
||||
const signupLimitResult = await signupRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const signupLimited = rateLimitResponse(c, signupLimitResult);
|
||||
|
||||
if (signupLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: signupLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Update password endpoint.
|
||||
*/
|
||||
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
|
||||
const { password, currentPassword } = c.req.valid('json');
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { session, user } = await getSession(c);
|
||||
|
||||
await updatePassword({
|
||||
userId: user.id,
|
||||
password,
|
||||
currentPassword,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: user.id satisfies number, // Incase we pass undefined somehow.
|
||||
id: {
|
||||
not: session.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((s) => s.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId: user.id,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Verify email endpoint.
|
||||
*/
|
||||
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const verifyLimitResult = await verifyEmailRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: token,
|
||||
});
|
||||
|
||||
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
|
||||
|
||||
if (verifyLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: verifyLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const { state, userId } = await verifyEmail({ token });
|
||||
|
||||
// If email is verified, automatically authenticate user.
|
||||
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
|
||||
await onAuthorize({ userId }, c);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
state,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Resend verification email endpoint.
|
||||
*/
|
||||
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const resendLimitResult = await resendVerifyEmailRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const resendLimited = rateLimitResponse(c, resendLimitResult);
|
||||
|
||||
if (resendLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: resendLimited,
|
||||
});
|
||||
}
|
||||
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Forgot password endpoint.
|
||||
*/
|
||||
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
|
||||
|
||||
if (forgotLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: forgotLimited,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
) {
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
await forgotPassword({
|
||||
email,
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Reset password endpoint.
|
||||
*/
|
||||
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
const resetLimitResult = await resetPasswordRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: token,
|
||||
});
|
||||
|
||||
const resetLimited = rateLimitResponse(c, resetLimitResult);
|
||||
|
||||
if (resetLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: resetLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await getUserByResetToken({ token });
|
||||
|
||||
if (
|
||||
user.email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
user.email.toLowerCase() === deletedServiceAccountEmail()
|
||||
) {
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
const { userId } = await resetPassword({
|
||||
token,
|
||||
password,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
// Invalidate all sessions after successful password reset
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: userId satisfies number, // Incase we pass undefined somehow.
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((session) => session.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
* Setup two factor authentication.
|
||||
*/
|
||||
.post('/2fa/setup', async (c) => {
|
||||
const { user } = await getSession(c);
|
||||
|
||||
const result = await setupTwoFactorAuthentication({
|
||||
user,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
secret: result.secret,
|
||||
uri: result.uri,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Enable two factor authentication.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/enable',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { code } = c.req.valid('json');
|
||||
|
||||
const result = await enableTwoFactorAuthentication({
|
||||
user,
|
||||
code,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
recoveryCodes: result.recoveryCodes,
|
||||
});
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Disable two factor authentication.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/disable',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
twoFactorBackupCodes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { totpCode, backupCode } = c.req.valid('json');
|
||||
|
||||
await disableTwoFactorAuthentication({
|
||||
user,
|
||||
totpCode,
|
||||
backupCode,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return c.text('OK', 201);
|
||||
},
|
||||
)
|
||||
/**
|
||||
* View backup codes.
|
||||
*/
|
||||
.post(
|
||||
'/2fa/view-recovery-codes',
|
||||
sValidator(
|
||||
'json',
|
||||
z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { user: sessionUser } = await getSession(c);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
twoFactorBackupCodes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const backupCodes = await viewBackupCodes({
|
||||
user,
|
||||
token,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
backupCodes,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,9 +2,8 @@ import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, HanzoAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { HanzoAuthOptions } from '../config';
|
||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
const ZOAuthAuthorizeSchema = z.object({
|
||||
@@ -13,33 +12,7 @@ const ZOAuthAuthorizeSchema = z.object({
|
||||
|
||||
export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* Google authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/google', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||
const { redirectPath } = c.req.valid('json');
|
||||
|
||||
return handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions: GoogleAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Microsoft authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||
const { redirectPath } = c.req.valid('json');
|
||||
|
||||
return handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions: MicrosoftAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Hanzo IAM authorize endpoint.
|
||||
* Hanzo IAM authorize endpoint — the one way to sign in.
|
||||
*/
|
||||
.post('/authorize/hanzo', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||
const { redirectPath } = c.req.valid('json');
|
||||
@@ -49,34 +22,4 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
clientOptions: HanzoAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* OIDC authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/oidc', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||
const { redirectPath } = c.req.valid('json');
|
||||
|
||||
return handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions: OidcAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Organisation OIDC authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return await handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions,
|
||||
prompt: 'select_account',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { rateLimitResponse } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { passkeyRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
|
||||
import { deletedServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@hanzo/esign-lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import type { TAuthenticationResponseJSONSchema } from '@hanzo/esign-lib/types/webauthn';
|
||||
import { ZAuthenticationResponseJSONSchema } from '@hanzo/esign-lib/types/webauthn';
|
||||
import { getAuthenticatorOptions } from '@hanzo/esign-lib/utils/authenticator';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { onAuthorize } from '../lib/utils/authorizer';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
import { ZPasskeyAuthorizeSchema } from '../types/passkey';
|
||||
|
||||
export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* Authorize endpoint.
|
||||
*/
|
||||
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const passkeyLimitResult = await passkeyRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
|
||||
|
||||
if (passkeyLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: passkeyLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const { csrfToken, credential } = c.req.valid('json');
|
||||
|
||||
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
let requestBodyCrediential: TAuthenticationResponseJSONSchema;
|
||||
|
||||
try {
|
||||
const parsedBodyCredential = JSON.parse(credential);
|
||||
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
const challengeToken = await prisma.anonymousVerificationToken
|
||||
.delete({
|
||||
where: {
|
||||
id: csrfToken,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!challengeToken) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
if (challengeToken.expiresAt < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const user = passkey.user;
|
||||
|
||||
if (
|
||||
user.email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
user.email.toLowerCase() === deletedServiceAccountEmail()
|
||||
) {
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
const { rpId, origin } = getAuthenticatorOptions();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: requestBodyCrediential,
|
||||
expectedChallenge: challengeToken.token,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: isoBase64URL.fromBuffer(passkey.credentialId),
|
||||
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
if (!verification?.verified) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
},
|
||||
});
|
||||
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
url: '/',
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCurrentPasswordSchema = z
|
||||
.string()
|
||||
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||
.max(72);
|
||||
|
||||
export const ZSignInSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: ZCurrentPasswordSchema,
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
csrfToken: z.string().trim(),
|
||||
});
|
||||
|
||||
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
|
||||
|
||||
export const ZPasswordSchema = z
|
||||
.string()
|
||||
.min(8, { message: 'Must be at least 8 characters in length' })
|
||||
.max(72, { message: 'Cannot be more than 72 characters in length' })
|
||||
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
|
||||
message: 'One uppercase character',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
|
||||
message: 'One lowercase character',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /\d/.test(value), {
|
||||
message: 'One number',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
|
||||
message: 'One special character is required',
|
||||
});
|
||||
|
||||
export const ZSignUpSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
||||
|
||||
export const ZForgotPasswordSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
|
||||
|
||||
export const ZResetPasswordSchema = z.object({
|
||||
password: ZPasswordSchema,
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TResetPasswordSchema = z.infer<typeof ZResetPasswordSchema>;
|
||||
|
||||
export const ZVerifyEmailSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;
|
||||
|
||||
export const ZResendVerifyEmailSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
|
||||
|
||||
export const ZUpdatePasswordSchema = z.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
});
|
||||
|
||||
export type TUpdatePasswordSchema = z.infer<typeof ZUpdatePasswordSchema>;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZPasskeyAuthorizeSchema = z.object({
|
||||
csrfToken: z.string().min(1),
|
||||
credential: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateConfirmationEmailProps = {
|
||||
confirmationLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateConfirmationEmail = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold text-primary">
|
||||
<Trans>Welcome to Hanzo Sign!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>
|
||||
Before you get started, please confirm your email address by clicking the button below:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-sign-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Confirm email</Trans>
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-sm italic text-slate-400">
|
||||
<Trans>
|
||||
You can also copy and paste this link into your browser: {confirmationLink} (link
|
||||
expires in 1 hour)
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { env } from '@hanzo/esign-lib/utils/env';
|
||||
|
||||
import { Button, Column, Img, Link, Section, Text } from '../components';
|
||||
import { Column, Img, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentSelfSignedProps {
|
||||
@@ -14,10 +12,6 @@ export const TemplateDocumentSelfSigned = ({
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentSelfSignedProps) => {
|
||||
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
|
||||
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
@@ -42,44 +36,6 @@ export const TemplateDocumentSelfSigned = ({
|
||||
<Text className="mb-0 mt-6 text-center text-lg font-semibold text-primary">
|
||||
<Trans>You have signed “{documentName}”</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
|
||||
<Trans>
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={signUpUrl}
|
||||
target="_blank"
|
||||
className="whitespace-nowrap text-sign-700 hover:text-sign-600"
|
||||
>
|
||||
free account
|
||||
</Link>{' '}
|
||||
to access your signed documents at any time.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
href={signUpUrl}
|
||||
className="mr-4 rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/user-plus.png')}
|
||||
className="mb-0.5 mr-2 inline h-5 w-5 align-middle"
|
||||
/>
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href="https://esign.hanzo.ai/pricing"
|
||||
>
|
||||
<Img
|
||||
src={getAssetUrl('/static/review.png')}
|
||||
className="mb-0.5 mr-2 inline h-5 w-5 align-middle"
|
||||
/>
|
||||
<Trans>View plans</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateForgotPasswordProps = {
|
||||
resetPasswordLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateForgotPassword = ({
|
||||
resetPasswordLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateForgotPasswordProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-sign-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
<Trans>Reset Password</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateForgotPassword;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { env } from '@hanzo/esign-lib/utils/env';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateResetPasswordProps {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
<Trans>Password updated!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Your password has been updated.</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-sign-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
|
||||
>
|
||||
<Trans>Sign In</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateResetPassword;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
|
||||
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Please confirm your email address`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Hanzo Sign Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateConfirmationEmail
|
||||
confirmationLink={confirmationLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmEmailTemplate;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
|
||||
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
|
||||
|
||||
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
||||
|
||||
export const ForgotPasswordTemplate = ({
|
||||
resetPasswordLink = 'https://esign.hanzo.ai',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ForgotPasswordTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Password Reset Requested`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Hanzo Sign Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateForgotPassword
|
||||
resetPasswordLink={resetPasswordLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordTemplate;
|
||||
@@ -1,145 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
type OrganisationAccountLinkConfirmationTemplateProps = {
|
||||
type: 'create' | 'link';
|
||||
confirmationLink: string;
|
||||
organisationName: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
type = 'link',
|
||||
confirmationLink = '<CONFIRMATION_LINK>',
|
||||
organisationName = '<ORGANISATION_NAME>',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: OrganisationAccountLinkConfirmationTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText =
|
||||
type === 'create'
|
||||
? msg`A request has been made to create an account for you`
|
||||
: msg`A request has been made to link your Hanzo Sign account`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto h-12 w-12"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="building-2.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
{type === 'create' ? (
|
||||
<Trans>Account creation request</Trans>
|
||||
) : (
|
||||
<Trans>Link your Hanzo Sign account</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
{type === 'create' ? (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to create an
|
||||
account on your behalf.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to link your
|
||||
current Hanzo Sign account to their organisation.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Placeholder text if we want to have the warning in the email as well. */}
|
||||
{/* <Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
<Trans>
|
||||
By accepting this request, you will be granting{' '}
|
||||
<strong>{organisationName}</strong> full access to:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
<Trans>Your account, and everything associated with it</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
<Trans>
|
||||
You can unlink your account at any time in your security settings on Hanzo Sign{' '}
|
||||
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section> */}
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-sign-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Review request</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">
|
||||
<Trans>Link expires in 30 minutes.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationAccountLinkConfirmationTemplate;
|
||||
@@ -1,95 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
|
||||
import { TemplateResetPassword } from '../template-components/template-reset-password';
|
||||
|
||||
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
||||
|
||||
export const ResetPasswordTemplate = ({
|
||||
userName = 'Lucas Smith',
|
||||
userEmail = '[email protected]',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ResetPasswordTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Password Reset Successful`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Hanzo Sign Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateResetPassword
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base font-semibold">
|
||||
<Trans>
|
||||
Hi, {userName}{' '}
|
||||
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
|
||||
({userEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Trans>
|
||||
We've changed your password as you asked. You can now sign in with your new
|
||||
password.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Trans>
|
||||
Didn't request a password change? We are here to help you secure your account,
|
||||
just{' '}
|
||||
<Link className="font-normal text-sign-700" href="mailto:[email protected]">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordTemplate;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* An organisation invitation joins the person it was sent to, and nobody else.
|
||||
*
|
||||
* Holding the link proves the invitation was received, not who received it, so
|
||||
* acceptance binds the signed-in person only when their verified address is the
|
||||
* invited one.
|
||||
*
|
||||
* Run: npx tsx --test packages/lib/__tests__/invitation.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
import { isInvitedPerson } from '../server-only/organisation/is-invited-person';
|
||||
|
||||
describe('isInvitedPerson', () => {
|
||||
const verified = new Date('2026-10-16T00:00:00Z');
|
||||
|
||||
test('the invited address, verified', () => {
|
||||
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'), true);
|
||||
});
|
||||
|
||||
test('case does not make a different person', () => {
|
||||
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'), true);
|
||||
});
|
||||
|
||||
test('a different address is refused', () => {
|
||||
assert.equal(
|
||||
isInvitedPerson({ email: '[email protected]', emailVerified: verified }, '[email protected]'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the invited address, never verified, is refused', () => {
|
||||
assert.equal(isInvitedPerson({ email: '[email protected]', emailVerified: null }, '[email protected]'), false);
|
||||
});
|
||||
});
|
||||
@@ -10,24 +10,26 @@ export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||
OIDC: 'OIDC',
|
||||
};
|
||||
|
||||
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
|
||||
);
|
||||
|
||||
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
|
||||
);
|
||||
|
||||
export const IS_OIDC_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
|
||||
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
|
||||
env('NEXT_PRIVATE_OIDC_CLIENT_SECRET'),
|
||||
);
|
||||
|
||||
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||
|
||||
export const IS_IAM_SSO_ENABLED = Boolean(env('IAM_CLIENT_ID'));
|
||||
|
||||
/**
|
||||
* Why a Hanzo IAM sign-in was turned away. The callback redirects to
|
||||
* `/signin?error=<reason>` and the sign-in page says it in a sentence, so the
|
||||
* person knows what to do next.
|
||||
*/
|
||||
export const SignInRefusal = {
|
||||
/** IAM has not proved the address the account would be named after. */
|
||||
UnverifiedEmail: 'unverified-email',
|
||||
/** An account holds this address but was never linked to an IAM sign-in. */
|
||||
UnlinkedAccount: 'unlinked-account',
|
||||
/** Nobody invited this person and they do not administer the org. */
|
||||
NotInvited: 'not-invited',
|
||||
/** An admin disabled the account. */
|
||||
Disabled: 'disabled',
|
||||
} as const;
|
||||
|
||||
export type SignInRefusal = (typeof SignInRefusal)[keyof typeof SignInRefusal];
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||
|
||||
@@ -127,6 +127,4 @@ export const isOrganisationUrlProtected = (url: string) => {
|
||||
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
|
||||
};
|
||||
|
||||
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
|
||||
|
||||
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
@@ -25,13 +23,11 @@ import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-e
|
||||
*/
|
||||
export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_EMAIL_JOB_DEFINITION,
|
||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
|
||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token';
|
||||
import type { TSendConfirmationEmailJobDefinition } from './send-confirmation-email';
|
||||
|
||||
export const run = async ({ payload }: { payload: TSendConfirmationEmailJobDefinition }) => {
|
||||
await sendConfirmationToken({
|
||||
email: payload.email,
|
||||
force: payload.force,
|
||||
});
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
|
||||
|
||||
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
email: z.string().email(),
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TSendConfirmationEmailJobDefinition = z.infer<
|
||||
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Confirmation Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload }) => {
|
||||
const handler = await import('./send-confirmation-email.handler');
|
||||
|
||||
await handler.run({ payload });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendConfirmationEmailJobDefinition
|
||||
>;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { sendResetPassword } from '../../../server-only/auth/send-reset-password';
|
||||
import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-password-reset-success-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
}: {
|
||||
payload: TSendPasswordResetSuccessEmailJobDefinition;
|
||||
}) => {
|
||||
await sendResetPassword({
|
||||
userId: payload.userId,
|
||||
});
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID = 'send.password.reset.success.email';
|
||||
|
||||
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendPasswordResetSuccessEmailJobDefinition = z.infer<
|
||||
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Password Reset Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload }) => {
|
||||
const handler = await import('./send-password-reset-success-email.handler');
|
||||
|
||||
await handler.run({ payload });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendPasswordResetSuccessEmailJobDefinition
|
||||
>;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeySigninOptions = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
|
||||
const { rpId, timeout } = getAuthenticatorOptions();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
userVerification: 'preferred',
|
||||
timeout,
|
||||
});
|
||||
|
||||
const { challenge } = options;
|
||||
|
||||
await prisma.anonymousVerificationToken.upsert({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
update: {
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@hanzo/esign-email/mailer';
|
||||
import { ConfirmEmailTemplate } from '@hanzo/esign-email/templates/confirm-email';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
SIGN_INTERNAL_EMAIL,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [verificationToken] = user.verificationTokens;
|
||||
|
||||
if (!verificationToken?.token) {
|
||||
throw new Error('Verification token not found for the user');
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(confirmationTemplate),
|
||||
renderEmailWithI18N(confirmationTemplate, { plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: SIGN_INTERNAL_EMAIL,
|
||||
subject: i18n._(msg`Please confirm your email`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@hanzo/esign-email/mailer';
|
||||
import { ForgotPasswordTemplate } from '@hanzo/esign-email/templates/forgot-password';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
export interface SendForgotPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
passwordResetTokens: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = user.passwordResetTokens[0].token;
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
|
||||
|
||||
const template = createElement(ForgotPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
resetPasswordLink,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
return await mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Hanzo Sign',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || '[email protected]',
|
||||
},
|
||||
subject: i18n._(msg`Forgot Password?`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@hanzo/esign-email/mailer';
|
||||
import { ResetPasswordTemplate } from '@hanzo/esign-email/templates/reset-password';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
export interface SendResetPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ResetPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
userEmail: user.email,
|
||||
userName: user.name || '',
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template),
|
||||
renderEmailWithI18N(template, { plainText: true }),
|
||||
]);
|
||||
|
||||
return await mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Hanzo Sign',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || '[email protected]',
|
||||
},
|
||||
subject: 'Password Reset Success!',
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -6,13 +6,25 @@ import { prisma } from '@hanzo/esign-prisma';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
import { isInvitedPerson } from './is-invited-person';
|
||||
|
||||
export type AcceptOrganisationInvitationOptions = {
|
||||
token: string;
|
||||
/** The signed-in person accepting. The invitation must name them. */
|
||||
userId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Accept an organisation invitation as the signed-in person it was sent to.
|
||||
*
|
||||
* The token proves the invitation was received; it does not say who received
|
||||
* it. So the invitation joins the person holding the session, and only when that
|
||||
* person's verified address is the address it was sent to. Holding the link is
|
||||
* never enough to put somebody else into the organisation.
|
||||
*/
|
||||
export const acceptOrganisationInvitation = async ({
|
||||
token,
|
||||
userId,
|
||||
}: AcceptOrganisationInvitationOptions) => {
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
|
||||
where: {
|
||||
@@ -40,18 +52,24 @@ export const acceptOrganisationInvitation = async ({
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
// Emails are stored lowercased (see create-user.ts); SQLite has no
|
||||
// case-insensitive filter `mode`, so normalise the invite email instead.
|
||||
email: organisationMemberInvite.email.toLowerCase(),
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User must exist to accept an organisation invitation',
|
||||
message: 'Sign in to accept an organisation invitation',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isInvitedPerson(user, organisationMemberInvite.email)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'This invitation was sent to a different address',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Whether user is the person an invitation sent to invitedEmail names: their
|
||||
* address is verified and is that address. Emails are stored lowercased (see
|
||||
* the invite schema), and SQLite has no case-insensitive filter, so both sides
|
||||
* are compared lowercased.
|
||||
*/
|
||||
export const isInvitedPerson = (
|
||||
user: { email: string; emailVerified: Date | null },
|
||||
invitedEmail: string,
|
||||
): boolean => {
|
||||
return user.emailVerified !== null && user.email.toLowerCase() === invitedEmail.toLowerCase();
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { getOrganisationAuthenticationPortalOptions } from '@hanzo/esign-auth/server/lib/utils/organisation-portal';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import {
|
||||
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '../../constants/organisations';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '../../types/organisation';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { addUserToOrganisation } from './accept-organisation-invitation';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
requestMeta: RequestMetadata;
|
||||
}
|
||||
|
||||
export const linkOrganisationAccount = async ({
|
||||
token,
|
||||
requestMeta,
|
||||
}: LinkOrganisationAccountOptions) => {
|
||||
// Delete the token since it contains unnecessary sensitive data.
|
||||
const verificationToken = await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationToken.completed) {
|
||||
throw new AppError('ALREADY_USED');
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
|
||||
verificationToken.metadata,
|
||||
);
|
||||
|
||||
if (!tokenMetadata.success) {
|
||||
console.error('Invalid token metadata', tokenMetadata.error);
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const user = verificationToken.user;
|
||||
|
||||
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'id',
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
});
|
||||
|
||||
const organisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
|
||||
const userAlreadyLinked = user.accounts.find(
|
||||
(account) =>
|
||||
account.provider === clientOptions.id &&
|
||||
account.providerAccountId === oauthConfig.providerAccountId,
|
||||
);
|
||||
|
||||
if (organisationMember && userAlreadyLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Membership carries its own transaction, so it runs once the link above has
|
||||
// committed and released the write lock.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
};
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { mailer } from '@hanzo/esign-email/mailer';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '@hanzo/esign-email/templates/organisation-account-link-confirmation';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { SIGN_INTERNAL_EMAIL } from '../../constants/email';
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/organisations';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TOrganisationAccountLinkMetadata } from '../../types/organisation';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
organisationName,
|
||||
oauthConfig,
|
||||
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [previousVerificationToken] = user.verificationTokens;
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
previousVerificationToken?.createdAt &&
|
||||
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token,
|
||||
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
|
||||
metadata: {
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
oauthConfig,
|
||||
} satisfies TOrganisationAccountLinkMetadata,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const { emailLanguage } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId,
|
||||
},
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
|
||||
|
||||
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
|
||||
type,
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
organisationName,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: SIGN_INTERNAL_EMAIL,
|
||||
subject:
|
||||
type === 'create'
|
||||
? i18n._(msg`Account creation request`)
|
||||
: i18n._(msg`Account linking request`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -2,26 +2,6 @@ import { createRateLimit } from './rate-limit';
|
||||
|
||||
// ---- Auth (Tier 1 - Critical, sends emails) ----
|
||||
|
||||
export const signupRateLimit = createRateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 10,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.forgot-password',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const resendVerifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.resend-verify-email',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const request2FAEmailRateLimit = createRateLimit({
|
||||
action: 'auth.request-2fa-email',
|
||||
max: 5,
|
||||
@@ -29,43 +9,6 @@ export const request2FAEmailRateLimit = createRateLimit({
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Auth (Tier 2 - Unauthenticated) ----
|
||||
|
||||
export const loginRateLimit = createRateLimit({
|
||||
action: 'auth.login',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const resetPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.reset-password',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const verifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.verify-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const passkeyRateLimit = createRateLimit({
|
||||
action: 'auth.passkey',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = createRateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
|
||||
@@ -1,67 +1,9 @@
|
||||
import { hash } from '@node-rs/bcrypt';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { createPersonalOrganisation } from '../organisation/create-organisation';
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
signature?: string | null;
|
||||
}
|
||||
|
||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: (RR7) Migrate to use this after RR7.
|
||||
// await tx.account.create({
|
||||
// data: {
|
||||
// userId: user.id,
|
||||
// type: 'emailPassword', // Todo: (RR7)
|
||||
// provider: 'HANZO_SIGN', // Todo: (RR7) Enums
|
||||
// providerAccountId: user.id.toString(),
|
||||
// password: hashedPassword,
|
||||
// },
|
||||
// });
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
// Not used at the moment, uncomment if required.
|
||||
await onCreateUserHook(user).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
/**
|
||||
* Should be run after a user is created, example during email password signup or google sign in.
|
||||
* Should be run after a user is created, on their first Hanzo IAM sign-in.
|
||||
*
|
||||
* @returns User
|
||||
*/
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { ONE_DAY } from '../../constants/time';
|
||||
import { sendForgotPassword } from '../auth/send-forgot-password';
|
||||
|
||||
export const forgotPassword = async ({ email }: { email: string }) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
// Emails are stored lowercased (see create-user.ts); normalise the lookup
|
||||
// to match. SQLite has no case-insensitive filter `mode`.
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a token that was created in the last hour and hasn't expired
|
||||
// const existingToken = await prisma.passwordResetToken.findFirst({
|
||||
// where: {
|
||||
// userId: user.id,
|
||||
// expiry: {
|
||||
// gt: new Date(),
|
||||
// },
|
||||
// createdAt: {
|
||||
// gt: new Date(Date.now() - ONE_HOUR),
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (existingToken) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const token = crypto.randomBytes(18).toString('hex');
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
expiry: new Date(Date.now() + ONE_DAY),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendForgotPassword({
|
||||
userId: user.id,
|
||||
}).catch((err) => console.error(err));
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
|
||||
export type getMostRecentEmailVerificationTokenOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentEmailVerificationToken = async ({
|
||||
userId,
|
||||
}: getMostRecentEmailVerificationTokenOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface GetUserByResetTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getUserByResetToken = async ({ token }: GetUserByResetTokenOptions) => {
|
||||
const result = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result || !result.user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import { compare, hash } from '@node-rs/bcrypt';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export type ResetPasswordOptions = {
|
||||
token: string;
|
||||
password: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
|
||||
if (!token) {
|
||||
throw new AppError('INVALID_TOKEN');
|
||||
}
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
throw new AppError('INVALID_TOKEN');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (now > foundToken.expiry) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE);
|
||||
}
|
||||
|
||||
const isSamePassword = await compare(password, foundToken.user.password || '');
|
||||
|
||||
if (isSamePassword) {
|
||||
throw new AppError('SAME_PASSWORD');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: foundToken.userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.passwordResetToken.deleteMany({
|
||||
where: {
|
||||
userId: foundToken.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: foundToken.userId,
|
||||
type: UserSecurityAuditLogType.PASSWORD_RESET,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.password.reset.success.email',
|
||||
payload: {
|
||||
userId: foundToken.userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userId: foundToken.userId,
|
||||
};
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
|
||||
|
||||
type SendConfirmationTokenOptions = { email: string; force?: boolean };
|
||||
|
||||
export const sendConfirmationToken = async ({
|
||||
email,
|
||||
force = false,
|
||||
}: SendConfirmationTokenOptions) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
throw new Error('Email verified');
|
||||
}
|
||||
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
!force &&
|
||||
mostRecentToken?.createdAt &&
|
||||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
// return;
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdToken) {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendConfirmationEmail({ userId: user.id });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new Error(`Failed to send the confirmation email`, { cause: err });
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { compare, hash } from '@node-rs/bcrypt';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { SALT_ROUNDS } from '@hanzo/esign-lib/constants/auth';
|
||||
import type { RequestMetadata } from '@hanzo/esign-lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
export type UpdatePasswordOptions = {
|
||||
userId: number;
|
||||
password: string;
|
||||
currentPassword: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updatePassword = async ({
|
||||
userId,
|
||||
password,
|
||||
currentPassword,
|
||||
requestMetadata,
|
||||
}: UpdatePasswordOptions) => {
|
||||
// Existence check
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user.password) {
|
||||
throw new AppError('NO_PASSWORD');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await compare(currentPassword, user.password);
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new AppError('INCORRECT_PASSWORD');
|
||||
}
|
||||
|
||||
// Compare the new password with the old password
|
||||
const isSamePassword = await compare(password, user.password);
|
||||
if (isSamePassword) {
|
||||
throw new AppError('SAME_PASSWORD');
|
||||
}
|
||||
|
||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedNewPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import {
|
||||
EMAIL_VERIFICATION_STATE,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return {
|
||||
state: EMAIL_VERIFICATION_STATE.NOT_FOUND,
|
||||
userId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// check if the token is valid or expired
|
||||
const valid = verificationToken.expires > new Date();
|
||||
|
||||
if (!valid) {
|
||||
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||
if (
|
||||
!mostRecentToken ||
|
||||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
|
||||
) {
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email: verificationToken.user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
state: EMAIL_VERIFICATION_STATE.EXPIRED,
|
||||
userId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (verificationToken.completed) {
|
||||
return {
|
||||
state: EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED,
|
||||
userId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const [updatedUser] = await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: {
|
||||
id: verificationToken.userId,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.verificationToken.updateMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
},
|
||||
}),
|
||||
// Tidy up old expired tokens
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
expires: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new Error('Something went wrong while verifying your email. Please try again.');
|
||||
}
|
||||
|
||||
return {
|
||||
state: EMAIL_VERIFICATION_STATE.VERIFIED,
|
||||
userId: updatedUser.id,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
|
||||
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
|
||||
};
|
||||
|
||||
export const formatOrganisationLoginPath = (organisationUrl: string) => {
|
||||
return `/o/${organisationUrl}/signin`;
|
||||
};
|
||||
|
||||
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/v1/auth/callback/oidc/org/${organisationUrl}`;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreatePasskeySigninOptionsRequestSchema = z.void();
|
||||
|
||||
export const ZCreatePasskeySigninOptionsResponseSchema = z.object({
|
||||
options: z.any(), // PublicKeyCredentialRequestOptions type
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export type TCreatePasskeySigninOptionsRequest = z.infer<
|
||||
typeof ZCreatePasskeySigninOptionsRequestSchema
|
||||
>;
|
||||
export type TCreatePasskeySigninOptionsResponse = z.infer<
|
||||
typeof ZCreatePasskeySigninOptionsResponseSchema
|
||||
>;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCurrentPasswordSchema = z
|
||||
.string()
|
||||
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||
.max(72);
|
||||
|
||||
export const ZPasswordSchema = z
|
||||
.string()
|
||||
.min(8, { message: 'Must be at least 8 characters in length' })
|
||||
.max(72, { message: 'Cannot be more than 72 characters in length' })
|
||||
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
|
||||
message: 'One uppercase character',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
|
||||
message: 'One lowercase character',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /\d/.test(value), {
|
||||
message: 'One number',
|
||||
})
|
||||
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
|
||||
message: 'One special character is required',
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TDeclineLinkOrganisationAccountRequest = z.infer<
|
||||
typeof ZDeclineLinkOrganisationAccountRequestSchema
|
||||
>;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@hanzo/esign-lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@hanzo/esign-lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@hanzo/esign-lib/utils/organisations';
|
||||
import { prisma } from '@hanzo/esign-prisma';
|
||||
|
||||
import {
|
||||
ZGetOrganisationAuthenticationPortalRequestSchema,
|
||||
ZGetOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './get-organisation-authentication-portal.types';
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortal = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: GetOrganisationAuthenticationPortalOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
clientSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
const portal = organisation.organisationAuthenticationPortal;
|
||||
|
||||
return {
|
||||
defaultOrganisationRole: portal.defaultOrganisationRole,
|
||||
enabled: portal.enabled,
|
||||
clientId: portal.clientId,
|
||||
wellKnownUrl: portal.wellKnownUrl,
|
||||
autoProvisionUsers: portal.autoProvisionUsers,
|
||||
allowedDomains: portal.allowedDomains,
|
||||
clientSecretProvided: Boolean(portal.clientSecret),
|
||||
};
|
||||
};
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationAuthenticationPortalSchema } from '@hanzo/esign-prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalResponseSchema =
|
||||
OrganisationAuthenticationPortalSchema.pick({
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
}).extend({
|
||||
/**
|
||||
* Whether we have the client secret in the database.
|
||||
*
|
||||
* Do not expose the actual client secret.
|
||||
*/
|
||||
clientSecretProvided: z.boolean(),
|
||||
});
|
||||
|
||||
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
|
||||
typeof ZGetOrganisationAuthenticationPortalResponseSchema
|
||||
>;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationMemberRoleSchema from '@hanzo/esign-prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||
|
||||
import { domainRegex } from './create-organisation-email-domain.types';
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
defaultOrganisationRole: OrganisationMemberRoleSchema,
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
|
||||
autoProvisionUsers: z.boolean(),
|
||||
allowedDomains: z.array(z.string().regex(domainRegex)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
|
||||
|
||||
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
|
||||
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
|
||||
>;
|
||||
@@ -1,24 +1,24 @@
|
||||
// esign ZAP handlers — auth router (fully ported, 7/7 procedures).
|
||||
// esign ZAP handlers — auth router: the signed-in person's passkeys.
|
||||
//
|
||||
// Verbatim tRPC procedure bodies rewired to (ctx, raw). Input is validated by
|
||||
// the SAME Zod schema the tRPC procedure used; the server-only functions are
|
||||
// reused unchanged. Routes:
|
||||
// auth.passkey.create / createAuthenticationOptions /
|
||||
// createRegistrationOptions / createSigninOptions / delete / find / update.
|
||||
// createRegistrationOptions / delete / find / update.
|
||||
//
|
||||
// A passkey re-authenticates a signed-in person for an action; it is never a
|
||||
// way to sign in, which is Hanzo IAM's alone.
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
|
||||
|
||||
import { createPasskey } from '@hanzo/esign-lib/server-only/auth/create-passkey';
|
||||
import { createPasskeyAuthenticationOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-authentication-options';
|
||||
import { createPasskeyRegistrationOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-registration-options';
|
||||
import { createPasskeySigninOptions } from '@hanzo/esign-lib/server-only/auth/create-passkey-signin-options';
|
||||
import { deletePasskey } from '@hanzo/esign-lib/server-only/auth/delete-passkey';
|
||||
import { findPasskeys } from '@hanzo/esign-lib/server-only/auth/find-passkeys';
|
||||
import { updatePasskey } from '@hanzo/esign-lib/server-only/auth/update-passkey';
|
||||
import { nanoid } from '@hanzo/esign-lib/universal/id';
|
||||
|
||||
import { ZCreatePasskeyAuthenticationOptionsRequestSchema } from '../../../server/auth-router/create-passkey-authentication-options.types';
|
||||
import { ZCreatePasskeyRegistrationOptionsRequestSchema } from '../../../server/auth-router/create-passkey-registration-options.types';
|
||||
import { ZCreatePasskeySigninOptionsRequestSchema } from '../../../server/auth-router/create-passkey-signin-options.types';
|
||||
import { ZCreatePasskeyRequestSchema } from '../../../server/auth-router/create-passkey.types';
|
||||
import { ZDeletePasskeyRequestSchema } from '../../../server/auth-router/delete-passkey.types';
|
||||
import { ZFindPasskeysRequestSchema } from '../../../server/auth-router/find-passkeys.types';
|
||||
@@ -58,21 +58,6 @@ export const authRoutes: ZapRouteMap = {
|
||||
});
|
||||
},
|
||||
|
||||
'auth.passkey.createSigninOptions': async (_ctx: ZapContext, raw) => {
|
||||
ZCreatePasskeySigninOptionsRequestSchema.parse(raw);
|
||||
|
||||
const sessionIdToken = nanoid(16);
|
||||
|
||||
const [sessionId] = decodeURI(sessionIdToken).split('|');
|
||||
|
||||
const options = await createPasskeySigninOptions({ sessionId });
|
||||
|
||||
return {
|
||||
options,
|
||||
sessionId,
|
||||
};
|
||||
},
|
||||
|
||||
'auth.passkey.delete': async (ctx: ZapContext, raw) => {
|
||||
const { passkeyId } = ZDeletePasskeyRequestSchema.parse(raw);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// esign ZAP handlers — organisation router (37/37 procedures).
|
||||
// esign ZAP handlers — organisation router.
|
||||
//
|
||||
// Verbatim tRPC procedure bodies rewired to (ctx, raw). Input is validated by
|
||||
// the SAME Zod schema the tRPC procedure used; the server-only functions and
|
||||
@@ -6,17 +6,12 @@
|
||||
// findOrganisationMembers / deleteOrganisationMembers / findOrganisationMemberInvites /
|
||||
// resendOrganisationMemberInvitation / findOrganisationGroups / findOrganisationEmails /
|
||||
// getOrganisationEmailDomain / findOrganisationEmailDomains /
|
||||
// getOrganisationAuthenticationPortal / getOrganisationSession) are reused
|
||||
// getOrganisationSession) are reused
|
||||
// unchanged. Route keys mirror the tRPC nested router shape (see
|
||||
// organisation-router.zap). The .meta() calls are dropped.
|
||||
//
|
||||
// Auth note: in tRPC, member.invite.accept / member.invite.decline used
|
||||
// maybeAuthenticatedProcedure and authenticationPortal.linkAccount /
|
||||
// declineLinkAccount used the unauthenticated `procedure`. Their bodies read
|
||||
// only `input` (and, for linkAccount, ctx.metadata) — never ctx.user — so they
|
||||
// port verbatim onto the authenticated ZapContext without behavioural change;
|
||||
// the ZAP transport establishes auth once at connection, and these handlers
|
||||
// simply don't depend on it.
|
||||
// An invitation is accepted by the person it names: member.invite.accept binds
|
||||
// the signed-in user, and only when their verified address is the invited one.
|
||||
import {
|
||||
OrganisationGroupType,
|
||||
OrganisationMemberInviteStatus,
|
||||
@@ -25,9 +20,7 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { unique } from 'remeda';
|
||||
|
||||
import { SIGN_ENCRYPTION_KEY } from '@hanzo/esign-lib/constants/crypto';
|
||||
import {
|
||||
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@hanzo/esign-lib/constants/organisations';
|
||||
@@ -40,12 +33,8 @@ import { orphanEnvelopes } from '@hanzo/esign-lib/server-only/envelope/orphan-en
|
||||
import { acceptOrganisationInvitation } from '@hanzo/esign-lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { createOrganisation } from '@hanzo/esign-lib/server-only/organisation/create-organisation';
|
||||
import { createOrganisationMemberInvites } from '@hanzo/esign-lib/server-only/organisation/create-organisation-member-invites';
|
||||
import { linkOrganisationAccount } from '@hanzo/esign-lib/server-only/organisation/link-organisation-account';
|
||||
import { assertRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { linkOrgAccountRateLimit } from '@hanzo/esign-lib/server-only/rate-limit/rate-limits';
|
||||
import { getMemberOrganisationRole } from '@hanzo/esign-lib/server-only/team/get-member-roles';
|
||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@hanzo/esign-lib/types/subscription';
|
||||
import { symmetricEncrypt } from '@hanzo/esign-lib/universal/crypto';
|
||||
import { generateDatabaseId } from '@hanzo/esign-lib/universal/id';
|
||||
import {
|
||||
buildOrganisationWhereQuery,
|
||||
@@ -65,7 +54,6 @@ import { ZCreateOrganisationEmailRequestSchema } from '../../../server/organisat
|
||||
import { ZCreateOrganisationGroupRequestSchema } from '../../../server/organisation-router/create-organisation-group.types';
|
||||
import { ZCreateOrganisationMemberInvitesRequestSchema } from '../../../server/organisation-router/create-organisation-member-invites.types';
|
||||
import { ZCreateOrganisationRequestSchema } from '../../../server/organisation-router/create-organisation.types';
|
||||
import { ZDeclineLinkOrganisationAccountRequestSchema } from '../../../server/organisation-router/decline-link-organisation-account.types';
|
||||
import { ZDeclineOrganisationMemberInviteRequestSchema } from '../../../server/organisation-router/decline-organisation-member-invite.types';
|
||||
import { ZDeleteOrganisationEmailDomainRequestSchema } from '../../../server/organisation-router/delete-organisation-email-domain.types';
|
||||
import { ZDeleteOrganisationEmailRequestSchema } from '../../../server/organisation-router/delete-organisation-email.types';
|
||||
@@ -86,8 +74,6 @@ import { ZFindOrganisationMemberInvitesRequestSchema } from '../../../server/org
|
||||
import { findOrganisationMembers } from '../../../server/organisation-router/find-organisation-members';
|
||||
import { ZFindOrganisationMembersRequestSchema } from '../../../server/organisation-router/find-organisation-members.types';
|
||||
import { getOrganisation } from '../../../server/organisation-router/get-organisation';
|
||||
import { getOrganisationAuthenticationPortal } from '../../../server/organisation-router/get-organisation-authentication-portal';
|
||||
import { ZGetOrganisationAuthenticationPortalRequestSchema } from '../../../server/organisation-router/get-organisation-authentication-portal.types';
|
||||
import { getOrganisationEmailDomain } from '../../../server/organisation-router/get-organisation-email-domain';
|
||||
import { ZGetOrganisationEmailDomainRequestSchema } from '../../../server/organisation-router/get-organisation-email-domain.types';
|
||||
import { ZGetOrganisationMemberInvitesRequestSchema } from '../../../server/organisation-router/get-organisation-member-invites.types';
|
||||
@@ -95,10 +81,8 @@ import { getOrganisationSession } from '../../../server/organisation-router/get-
|
||||
import { ZGetOrganisationRequestSchema } from '../../../server/organisation-router/get-organisation.types';
|
||||
import { getOrganisations } from '../../../server/organisation-router/get-organisations';
|
||||
import { ZLeaveOrganisationRequestSchema } from '../../../server/organisation-router/leave-organisation.types';
|
||||
import { ZLinkOrganisationAccountRequestSchema } from '../../../server/organisation-router/link-organisation-account.types';
|
||||
import { resendOrganisationMemberInvitation } from '../../../server/organisation-router/resend-organisation-member-invite';
|
||||
import { ZResendOrganisationMemberInviteRequestSchema } from '../../../server/organisation-router/resend-organisation-member-invite.types';
|
||||
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '../../../server/organisation-router/update-organisation-authentication-portal.types';
|
||||
import { ZUpdateOrganisationEmailRequestSchema } from '../../../server/organisation-router/update-organisation-email.types';
|
||||
import { ZUpdateOrganisationGroupRequestSchema } from '../../../server/organisation-router/update-organisation-group.types';
|
||||
import { ZUpdateOrganisationMemberRequestSchema } from '../../../server/organisation-router/update-organisation-members.types';
|
||||
@@ -585,11 +569,12 @@ export const organisationRoutes: ZapRouteMap = {
|
||||
});
|
||||
},
|
||||
|
||||
'organisation.member.invite.accept': async (_ctx: ZapContext, raw) => {
|
||||
'organisation.member.invite.accept': async (ctx: ZapContext, raw) => {
|
||||
const { token } = ZAcceptOrganisationMemberInviteRequestSchema.parse(raw);
|
||||
|
||||
return await acceptOrganisationInvitation({
|
||||
token,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1350,129 +1335,6 @@ export const organisationRoutes: ZapRouteMap = {
|
||||
await Promise.all(emailsToVerify.map(async (email) => verifyEmailDomain(email.id)));
|
||||
},
|
||||
|
||||
'organisation.authenticationPortal.get': async (ctx: ZapContext, raw) => {
|
||||
const { organisationId } = ZGetOrganisationAuthenticationPortalRequestSchema.parse(raw);
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getOrganisationAuthenticationPortal({
|
||||
userId: ctx.user.id,
|
||||
organisationId,
|
||||
});
|
||||
},
|
||||
|
||||
'organisation.authenticationPortal.update': async (ctx: ZapContext, raw) => {
|
||||
const { organisationId, data } =
|
||||
ZUpdateOrganisationAuthenticationPortalRequestSchema.parse(raw);
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationAuthenticationPortal: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
enabled &&
|
||||
(!wellKnownUrl ||
|
||||
!clientId ||
|
||||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message:
|
||||
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Allow empty string to be passed in to remove the client secret from the database.
|
||||
let encryptedClientSecret: string | undefined = clientSecret;
|
||||
|
||||
// Encrypt the secret if it is provided.
|
||||
if (clientSecret) {
|
||||
const encryptionKey = SIGN_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Missing SIGN_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
encryptedClientSecret = symmetricEncrypt({
|
||||
key: encryptionKey,
|
||||
data: clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationAuthenticationPortal.update({
|
||||
where: {
|
||||
id: organisation.organisationAuthenticationPortal.id,
|
||||
},
|
||||
data: {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'organisation.authenticationPortal.linkAccount': async (ctx: ZapContext, raw) => {
|
||||
const { token } = ZLinkOrganisationAccountRequestSchema.parse(raw);
|
||||
|
||||
const rateLimitResult = await linkOrgAccountRateLimit.check({
|
||||
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: token,
|
||||
});
|
||||
|
||||
assertRateLimit(rateLimitResult);
|
||||
|
||||
await linkOrganisationAccount({
|
||||
token,
|
||||
requestMeta: ctx.metadata.requestMetadata,
|
||||
});
|
||||
},
|
||||
|
||||
'organisation.authenticationPortal.declineLinkAccount': async (_ctx: ZapContext, raw) => {
|
||||
const { token } = ZDeclineLinkOrganisationAccountRequestSchema.parse(raw);
|
||||
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'organisation.internal.getOrganisationSession': async (ctx: ZapContext) => {
|
||||
return await getOrganisationSession({ userId: ctx.user.id });
|
||||
},
|
||||
|
||||
Vendored
-9
@@ -4,15 +4,6 @@ declare namespace NodeJS {
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PRIVATE_SIGN_LICENSE_KEY?: string;
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN?: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID?: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET?: string;
|
||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL?: string;
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
|
||||
|
||||
// Base SQLite (single shared file). BASE_PATH is the directory that contains
|
||||
// the one `sign.db` file; DATABASE_URL is the `file:` URL Prisma / the CLI /
|
||||
// migrations open. Tenancy is enforced in-query via buildTeamWhereQuery, not
|
||||
|
||||
@@ -96,12 +96,6 @@ services:
|
||||
- key: NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY
|
||||
generateValue: true
|
||||
|
||||
# Auth Optional
|
||||
- key: NEXT_PRIVATE_GOOGLE_CLIENT_ID
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_GOOGLE_CLIENT_SECRET
|
||||
sync: false
|
||||
|
||||
# Signing
|
||||
- key: NEXT_PRIVATE_SIGNING_TRANSPORT
|
||||
sync: false
|
||||
|
||||
@@ -71,13 +71,6 @@
|
||||
"NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY",
|
||||
"NEXT_PUBLIC_SIGNING_CONTACT_INFO",
|
||||
"NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
|
||||
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
|
||||
"NEXT_PRIVATE_OIDC_CLIENT_ID",
|
||||
"NEXT_PRIVATE_OIDC_CLIENT_SECRET",
|
||||
"NEXT_PRIVATE_OIDC_PROVIDER_LABEL",
|
||||
"NEXT_PRIVATE_OIDC_SKIP_VERIFY",
|
||||
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
|
||||
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
|
||||
"NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE",
|
||||
|
||||
Reference in New Issue
Block a user