feat: add invite acceptance screen for new users
Some checks failed
CI / backend (push) Failing after 18s
CI / frontend (push) Successful in 41s
CI / docker (push) Has been skipped

Add AcceptInviteScreen component to handle user invitation flow with password setup. Add route detection for /accept-invite path in Shell component. Add translation keys for invite acceptance UI in German and English dictionaries (acceptInvite, acceptInviteSubtitle, inviteMissingToken, inviteError, setPassword, saving).
This commit is contained in:
2026-06-04 11:09:32 +02:00
parent 5ed613d441
commit d6ff3b5c38
3 changed files with 67 additions and 2 deletions

View File

@@ -3,7 +3,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
import { I18nContext } from './contexts/I18nContext';
import { dictionaries } from './i18n/dictionaries';
import { AppLayout, type View } from './components/Layout';
import { getSetupStatus, LoginScreen, SetupWizard } from './pages/AuthScreens';
import { AcceptInviteScreen, getSetupStatus, LoginScreen, SetupWizard } from './pages/AuthScreens';
import { HomePage } from './pages/HomePage';
import { InventoryPage } from './pages/InventoryPage';
import { ShoppingPage } from './pages/ShoppingPage';
@@ -20,7 +20,9 @@ function languageFromBrowser(): Language {
function Shell() {
const { user, loading } = useAuth();
const [view, setView] = useState<View>('home');
const isAcceptingInvite = window.location.pathname === '/accept-invite';
if (loading) return <div className="grid min-h-dvh place-items-center dark:bg-gray-950 dark:text-white">NexaPantry</div>;
if (isAcceptingInvite) return <AcceptInviteScreen />;
if (!user) return <LoginScreen />;
if (!user.onboarding_completed) return <Onboarding />;
return (

View File

@@ -13,6 +13,12 @@ export const dictionaries: Record<Language, Record<string, string>> = {
instanceName: 'Instanzname',
timezone: 'Zeitzone',
completeSetup: 'Setup abschließen',
acceptInvite: 'Einladung annehmen',
acceptInviteSubtitle: 'Lege dein Passwort fest, um NexaPantry zu nutzen.',
inviteMissingToken: 'Der Einladungslink ist unvollständig.',
inviteError: 'Die Einladung ist ungültig oder abgelaufen.',
setPassword: 'Passwort festlegen',
saving: 'Speichern...',
login: 'Anmelden',
logout: 'Abmelden',
home: 'Home',
@@ -84,6 +90,12 @@ export const dictionaries: Record<Language, Record<string, string>> = {
instanceName: 'Instance name',
timezone: 'Timezone',
completeSetup: 'Complete setup',
acceptInvite: 'Accept invitation',
acceptInviteSubtitle: 'Set your password to start using NexaPantry.',
inviteMissingToken: 'The invitation link is incomplete.',
inviteError: 'The invitation is invalid or expired.',
setPassword: 'Set password',
saving: 'Saving...',
login: 'Sign in',
logout: 'Sign out',
home: 'Home',
@@ -144,4 +156,3 @@ export const dictionaries: Record<Language, Record<string, string>> = {
systemTheme: 'System'
}
};

View File

@@ -61,6 +61,58 @@ export function LoginScreen() {
);
}
export function AcceptInviteScreen() {
const { refresh } = useAuth();
const { language, t } = useI18n();
const token = new URLSearchParams(window.location.search).get('token') ?? '';
const [form, setForm] = useState({
name: '',
password: '',
language,
theme: 'system' as Theme
});
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const set = (key: string, value: string) => setForm((current) => ({ ...current, [key]: value }));
return (
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
<Panel>
<form className="grid w-full max-w-sm gap-4" onSubmit={async (event) => {
event.preventDefault();
setError('');
setSubmitting(true);
try {
await api('/auth/accept-invite', { method: 'POST', body: JSON.stringify({ ...form, token }) });
window.history.replaceState(null, '', '/');
await refresh();
} catch {
setError(t('inviteError'));
} finally {
setSubmitting(false);
}
}}>
<div>
<h1 className="text-3xl font-bold text-gray-950 dark:text-white">{t('acceptInvite')}</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{t('acceptInviteSubtitle')}</p>
</div>
{!token ? <p className="rounded-lg bg-rose-50 p-3 text-sm text-rose-800 dark:bg-rose-950 dark:text-rose-100">{t('inviteMissingToken')}</p> : null}
{error ? <p className="rounded-lg bg-rose-50 p-3 text-sm text-rose-800 dark:bg-rose-950 dark:text-rose-100">{error}</p> : null}
<Field label={t('name')} required value={form.name} onChange={(e) => set('name', e.target.value)} />
<Field label={t('password')} required type="password" minLength={12} value={form.password} onChange={(e) => set('password', e.target.value)} />
<SelectField label={t('language')} value={form.language} onChange={(e) => set('language', e.target.value)}>
<option value="de">Deutsch</option><option value="en">English</option>
</SelectField>
<SelectField label={t('theme')} value={form.theme} onChange={(e) => set('theme', e.target.value)}>
<option value="light">{t('light')}</option><option value="dark">{t('dark')}</option><option value="system">{t('systemTheme')}</option>
</SelectField>
<Button type="submit" disabled={!token || submitting}>{submitting ? t('saving') : t('setPassword')}</Button>
</form>
</Panel>
</div>
);
}
export async function getSetupStatus(): Promise<SetupStatus> {
return api<SetupStatus>('/setup/status');
}