feat: add invite acceptance screen for new users
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:
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user