Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The defaults are for local use. Before anyone else can reach your instance, copy
| File storage | `STORAGE=local` (default, the `files` volume; needs a filesystem with hard links, otherwise writes fall back to a non-atomic create and the store logs a warning) or `STORAGE=s3` with `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` (AWS S3, MinIO, Cloudflare R2, GCS with HMAC keys). With S3 the `api` and `worker` roles need no shared volume. The credentials need `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject` and `s3:ListBucket` (without ListBucket a missing file reads as 403, not 404) |
| File parsing sandbox | `EXTRACT_URL` on the kernel (Compose sets it). `crove-extract` takes no secrets: its image refuses to start with any variable outside its allowlist (its `EXTRACT_*` settings, `HOST`, `PORT`, `NODE_ENV` and what the container runtime sets), whatever `NODE_ENV` says. On Kubernetes set `enableServiceLinks: false` on its pod, or the injected `*_SERVICE_HOST` and `*_PORT` variables refuse the start |
| Database pooling | `DB_RESET_ON_RELEASE` (default `true`: each pooled connection's session is reset before reuse); set `false` only behind a transaction-mode pooler such as PgBouncer or Supavisor, whose own reset then governs (plan §3.3) |
| Sessions and MFA | `AUTH_ISSUER` (required), `AUTH_SESSION_CHECK`, `AUTH_STEP_UP`; without Compose, run the image's `auth-grants` role after Supabase Auth's first start and after each Supabase Auth upgrade. With a remote Supabase Auth (`AUTH_SESSION_CHECK=jwt`, the default without `AUTH_UPSTREAM_URL`), set `PUBLIC_AUTH_URL` (and `PUBLIC_AUTH_ANON_KEY` for hosted Supabase): R3 actions are confirmed live there, and a sign-out outside Crove takes effect when the access token expires (ADR-0028) |
| Sessions and MFA | `AUTH_ISSUER` (required), `AUTH_SESSION_CHECK`, `AUTH_STEP_UP` (default `required`: approving and membership changes ask for a two-factor code from the last 5 minutes; users enable two-factor under Settings); without Compose, run the image's `auth-grants` role after Supabase Auth's first start and after each Supabase Auth upgrade. With a remote Supabase Auth (`AUTH_SESSION_CHECK=jwt`, the default without `AUTH_UPSTREAM_URL`), set `PUBLIC_AUTH_URL` (and `PUBLIC_AUTH_ANON_KEY` for hosted Supabase): R3 actions are confirmed live there, and a sign-out outside Crove takes effect when the access token expires (ADR-0028) |

See [`.env.example`](./.env.example) for the full list.

Expand Down
258 changes: 258 additions & 0 deletions apps/app/src/components/two-factor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useTranslations } from 'use-intl';
import { auth, authErrorMessage } from '../lib/auth.ts';
import { Badge } from '@crove/ui/components/badge';
import { Button } from '@crove/ui/components/button';
import { Card } from '@crove/ui/components/card';
import { Input } from '@crove/ui/components/input';
import { Label } from '@crove/ui/components/label';
import { ErrorText, Notice, Spinner } from './ui.tsx';

interface Enrollment {
factorId: string;
qrCode: string;
secret: string;
}

// The signed-in user's Supabase Auth MFA factors; `totp` lists the verified ones.
export function useFactors() {
return useQuery({
queryKey: ['auth', 'factors'],
queryFn: async () => {
const { data, error } = await auth().mfa.listFactors();
if (error) throw error;
return data;
},
});
}

// Verifies a code from the user's authenticator app, which raises the session to aal2.
export function MfaChallengeForm({ onVerified }: { onVerified: () => Promise<void> | void }) {
const root = useTranslations();
const t = useTranslations('twoFactor');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
const { data: factors, error: listError } = await auth().mfa.listFactors();
if (listError) return setError(authErrorMessage(listError, root));
const factor = factors.totp[0];
if (!factor) return setError(t('noFactor'));
const { error: verifyError } = await auth().mfa.challengeAndVerify({ factorId: factor.id, code: code.trim() });
if (verifyError) {
setCode('');
return setError(authErrorMessage(verifyError, root));
}
await onVerified();
} finally {
setBusy(false);
}
}

return (
<form className="space-y-3" onSubmit={submit}>
<Label className="flex-col items-stretch gap-1 font-normal">
<span className="text-muted-foreground">{t('code')}</span>
<Input
required
autoFocus
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder={t('codePlaceholder')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
</Label>
{error && <ErrorText>{error}</ErrorText>}
<Button type="submit" size="lg" className="w-full" disabled={busy}>
{busy ? t('checking') : t('confirm')}
</Button>
</form>
);
}

// TOTP enrollment for the signed-in user (Supabase Auth MFA). A verified factor makes the next sign-in aal2.
export function TwoFactorCard({ onEnabled }: { onEnabled?: () => void }) {
const root = useTranslations();
const t = useTranslations('twoFactor');
const factors = useFactors();
const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const verified = factors.data?.totp ?? [];

async function run(action: () => Promise<string | null>) {
setBusy(true);
setError(null);
setNotice(null);
try {
setError(await action());
} finally {
setBusy(false);
await factors.refetch();
}
}

const start = () =>
run(async () => {
// An abandoned setup leaves an unverified factor behind; remove it so they do not pile up.
for (const f of factors.data?.all ?? []) {
if (f.factor_type === 'totp' && f.status !== 'verified') await auth().mfa.unenroll({ factorId: f.id });
}
const { data, error: e } = await auth().mfa.enroll({ factorType: 'totp', issuer: 'Crove' });
if (e) return authErrorMessage(e, root);
setCode('');
setEnrollment({ factorId: data.id, qrCode: data.totp.qr_code, secret: data.totp.secret });
return null;
Comment thread
JOY marked this conversation as resolved.
});

const confirm = (event: FormEvent) => {
event.preventDefault();
if (!enrollment) return;
void run(async () => {
const { error: e } = await auth().mfa.challengeAndVerify({ factorId: enrollment.factorId, code: code.trim() });
if (e) return authErrorMessage(e, root);
setEnrollment(null);
setNotice(t('enabled'));
onEnabled?.();
return null;
});
};

const cancel = () =>
run(async () => {
if (enrollment) await auth().mfa.unenroll({ factorId: enrollment.factorId });
setEnrollment(null);
return null;
});

const disable = (factorId: string) => {
if (!window.confirm(t('confirmDisable'))) return;
void run(async () => {
const { error: e } = await auth().mfa.unenroll({ factorId });
if (e) return authErrorMessage(e, root);
setNotice(t('disabled'));
return null;
});
};

return (
<Card className="block p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium tracking-wide uppercase text-subtle-foreground">{t('title')}</p>
{factors.isSuccess && (verified.length > 0 ? <Badge variant="success">{t('on')}</Badge> : <Badge variant="secondary">{t('off')}</Badge>)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{t('intro')}</p>
<div className="mt-4 space-y-3">
{factors.isLoading && <Spinner />}
{factors.error && <ErrorText>{authErrorMessage(factors.error as { code?: string; message: string }, root)}</ErrorText>}
{enrollment ? (
<form className="space-y-3" onSubmit={confirm}>
<p className="text-sm text-muted-foreground">{t('scan')}</p>
{/* The QR code draws black modules on a transparent background: it needs a light backdrop in both themes. */}
<img src={enrollment.qrCode} alt={t('qrAlt')} className="h-44 w-44 rounded-lg bg-primary-foreground p-2" />
<div className="text-xs text-subtle-foreground">
{t('manualKey')}
<code className="mt-1 block break-all font-mono text-sm text-foreground" aria-label={t('setupKey')}>
{enrollment.secret}
</code>
</div>
<Label className="flex-col items-stretch gap-1 font-normal">
<span className="text-muted-foreground">{t('code')}</span>
<Input
required
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder={t('codePlaceholder')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
</Label>
<div className="flex gap-2">
<Button type="submit" disabled={busy}>
{t('confirm')}
</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={() => void cancel()}>
{root('common.cancel')}
</Button>
</div>
</form>
) : factors.isSuccess && verified.length > 0 ? (
verified.map((f) => (
<Button key={f.id} variant="destructive" disabled={busy} onClick={() => disable(f.id)}>
{t('disable')}
</Button>
))
) : (
factors.isSuccess && (
<Button disabled={busy} onClick={() => void start()}>
{busy ? <Spinner /> : null}
{t('enable')}
</Button>
)
)}
{error && <ErrorText>{error}</ErrorText>}
{notice && <Notice>{notice}</Notice>}
</div>
</Card>
);
}

// Shown when the kernel answers STEP_UP_REQUIRED (policy P11): approving and other R3 work need a second factor
// verified in the last 5 minutes. With a factor, the user enters a fresh code; without one, they enable it here
// (or, where `enroll` is false, in the two-factor card on the same page). Either way `onVerified` repeats the request
// that was refused, which `action` names; `onCancel` drops it.
export function StepUpPrompt({
action,
onVerified,
onCancel,
enroll = true,
}: {
action: string;
onVerified: () => void;
onCancel?: () => void;
enroll?: boolean;
}) {
const root = useTranslations();
const t = useTranslations('twoFactor.stepUp');
const factors = useFactors();
if (factors.isLoading) return <Spinner />;
if (factors.error) return <ErrorText>{authErrorMessage(factors.error as { code?: string; message: string }, root)}</ErrorText>;
const pending = <p className="text-sm text-muted-foreground">{t('pending', { action })}</p>;
const cancel = onCancel && (
<Button type="button" variant="ghost" onClick={onCancel}>
{t('cancel')}
</Button>
);
if ((factors.data?.totp.length ?? 0) > 0) {
Comment thread
JOY marked this conversation as resolved.
return (
<Card className="block max-w-sm space-y-3 p-4">
<p className="text-sm font-medium text-foreground">{t('title')}</p>
<p className="text-sm text-muted-foreground">{t('recent')}</p>
{pending}
<MfaChallengeForm onVerified={onVerified} />
{cancel}
</Card>
);
}
return (
<div className="max-w-md space-y-3">
<p className="text-sm text-muted-foreground">{enroll ? t('enrollHere') : t('enrollBeside')}</p>
{pending}
{enroll && <TwoFactorCard onEnabled={onVerified} />}
{cancel}
</div>
);
}
9 changes: 9 additions & 0 deletions apps/app/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export function ErrorText({ children }: { children: ReactNode }) {
);
}

// Something that worked and the user should read (an email link confirmed, a setting saved); a status, not an alert.
export function Notice({ children }: { children: ReactNode }) {
return (
<Alert role="status" className="border-success/30 bg-success/10 text-success">
{children}
</Alert>
);
}

export function Spinner({ className }: { className?: string }) {
const t = useTranslations('common');
return <SpinnerIcon className={cn('text-ring', className)} aria-label={t('loading')} />;
Expand Down
59 changes: 57 additions & 2 deletions apps/app/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ export const en: Messages = {
tagline: 'Document inbox: forward invoices, AI proposes the entries, you approve.',
email: 'Email',
password: 'Password',
signupSent: 'We sent a confirmation email. Open it, then come back to sign in.',
resetSent: 'If this email has an account, a password reset email is on its way.',
signupSent: 'We sent a confirmation email. Open the link in it in this browser to get into Crove.',
resetSent: 'If this email has an account, a password reset email is on its way. Open the link in it in this browser.',
linkInvalid: 'This link is invalid or has expired. Ask for a new one.',
linkVerified:
'The link was verified. If you just confirmed your email, sign in. If you are resetting your password, open the link in the browser you asked for it from.',
forgotLink: 'Forgot password?',
haveAccount: 'Already have an account? Sign in',
errors: {
Expand All @@ -49,6 +52,14 @@ export const en: Messages = {
email_not_confirmed: 'Email not confirmed yet, open the confirmation email',
weak_password: 'Password too weak (at least 8 characters)',
over_email_send_rate_limit: 'Too many emails sent, try again later',
same_password: 'The new password must differ from the old one',
otp_expired: 'This link has expired or was already used. Ask for a new one.',
flow_state_expired: 'This link has expired. Ask for a new one.',
flow_state_not_found: 'This link is no longer valid. Ask for a new one.',
bad_code_verifier: 'Open the link in the browser you asked for it from.',
mfa_verification_failed: 'The code is wrong or has expired',
mfa_challenge_expired: 'The code has expired, enter a new one',
insufficient_aal: 'Enter your two-factor code first',
},
},
onboarding: {
Expand Down Expand Up @@ -188,6 +199,7 @@ export const en: Messages = {
reject: 'Reject',
saveEdit: 'Save changes',
approve: 'Approve and post',
stepUpApprove: 'approve and post this document',
executedAt: 'Posted {at}',
rejected: 'Rejected',
reading:
Expand Down Expand Up @@ -304,6 +316,10 @@ export const en: Messages = {
'The current address stops receiving mail at once and is never issued again. Mail sent to the old address is dropped without telling the sender, so send the new address to your vendors.',
rotateMfa: 'This needs two-factor authentication (MFA) in the last 5 minutes. Each organization can change it only a few times a day.',
rotateConfirm: 'Confirm address change',
stepUpRemove: 'remove {name} from the organization',
stepUpRole: 'change the role of {name} to {role}',
stepUpRotate: 'change the invoice email address',
stepUpInvite: 'invite {email} as {role}',
inviteCreated: 'Invitation created for {email}.',
inviteMailSent: 'Crove emails the invitation to this address. If the invitee does not receive it, send them the link below.',
inviteMailOff: 'This server cannot send email, so Crove did not email the invitation. Send the link below to the invitee.',
Expand All @@ -320,6 +336,45 @@ export const en: Messages = {
invitedBy: 'invited by {name}',
revoke: 'Revoke',
},
twoFactor: {
title: 'Two-factor authentication',
signInIntro: 'Enter the 6-digit code from your authenticator app to finish signing in.',
code: 'Authentication code',
codePlaceholder: '6 digits',
checking: 'Checking…',
confirm: 'Confirm',
noFactor: 'This account has no authenticator app',
on: 'On',
off: 'Off',
intro:
'Each sign-in also asks for a 6-digit code from an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password...). Recommended for everyone who approves postings.',
scan: 'Scan the QR code with your authenticator app, then enter the code it shows.',
qrAlt: 'QR code to add Crove to your authenticator app',
manualKey: 'Cannot scan it? Enter this key in the app:',
setupKey: 'Setup key',
enable: 'Turn on two-factor authentication',
disable: 'Turn off two-factor authentication',
confirmDisable: 'Turn off two-factor authentication? The account will be protected by the password alone.',
enabled: 'Two-factor authentication is on. Your next sign-in will ask for a code from the app.',
disabled: 'Two-factor authentication is off.',
stepUp: {
title: 'Verify again to continue',
recent: 'This needs a two-factor code entered in the last 5 minutes.',
pending: 'Waiting to {action}.',
cancel: 'Cancel',
enrollHere: 'This needs two-factor authentication. Turn it on for your account and Crove will retry right after.',
enrollBeside: 'This needs two-factor authentication. Turn it on in the card next to this one, then try again.',
},
},
resetPassword: {
title: 'Set a new password',
needsCode: 'This account uses two-factor authentication. Enter the code before setting a new password.',
newPassword: 'New password',
repeat: 'Repeat the new password',
mismatch: 'The passwords do not match',
saving: 'Saving…',
save: 'Save password',
},
sessions: {
title: 'Signed-in devices',
intro: 'The Crove sessions seen from your account. Revoking a session signs that device out of every organization from its next request.',
Expand Down
Loading
Loading