refactored Authentication
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||||
|
import { LandingPage, AuthLayout } from './Screens/LandingPage';
|
||||||
|
import { Home } from './Screens/Dashboard/Home';
|
||||||
|
import { ProfileSettings } from './Screens/Dashboard/Settings/ProfileSettings';
|
||||||
|
import { ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat';
|
||||||
|
import { Plans } from './Screens/Dashboard/Plans';
|
||||||
|
import { Skills } from './Screens/Dashboard/Skills';
|
||||||
|
import { Tasks } from './Screens/Dashboard/Tasks';
|
||||||
|
import { Processes } from './Screens/Dashboard/Processes';
|
||||||
|
import { TaskLogs } from './Screens/Dashboard/TaskLogs';
|
||||||
|
import { SignoutScreen } from './Screens/Dashboard/SignoutScreen';
|
||||||
|
import { Screen as Files } from 'plugins/FileBrowser/client';
|
||||||
|
import { Screen as Terminal } from 'plugins/Terminal/client';
|
||||||
|
import { AISettings } from './Screens/Dashboard/Settings/AISettings';
|
||||||
|
import { ServerSettings } from './Screens/Dashboard/Settings/ServerSettings';
|
||||||
|
import { ResourceSettings } from './Screens/Dashboard/Settings/ResourceSettings';
|
||||||
|
import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin';
|
||||||
|
|
||||||
|
import { ChatList } from './Screens/Dashboard/Chat/ChatList';
|
||||||
|
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useServerSettings } from '@/state/useServerSettings';
|
||||||
|
import { useInitialData } from '@/state/useInitialData';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { isLoading, isAuthenticated } = useAuth();
|
||||||
|
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
||||||
|
useInitialData();
|
||||||
|
|
||||||
|
if (isLoading || isServerSettingsLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/auth/*" element={<AuthLayout />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && !onboardingComplete && (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/onboarding-admin" element={<OnboardingAdmin />} />
|
||||||
|
<Route path="/auth/signout" element={<SignoutScreen />} />
|
||||||
|
<Route path="*" element={<Navigate to="/onboarding-admin" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && onboardingComplete && (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||||
|
<Route path="/settings/ai" element={<AISettings />} />
|
||||||
|
<Route path="/settings/server" element={<ServerSettings />} />
|
||||||
|
<Route path="/settings/resources" element={<ResourceSettings />} />
|
||||||
|
<Route path="/chat" element={<ChatList />} />
|
||||||
|
<Route path="/chat/new" element={<NewChat />} />
|
||||||
|
<Route path="/chat/:sessionId" element={<ClaudeChat />} />
|
||||||
|
<Route path="/chat/opencode/new" element={<OpenCodeChat />} />
|
||||||
|
<Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} />
|
||||||
|
<Route path="/files" element={<Files />} />
|
||||||
|
<Route path="/terminal" element={<Terminal />} />
|
||||||
|
<Route path="/plans" element={<Plans />} />
|
||||||
|
<Route path="/skills" element={<Skills />} />
|
||||||
|
<Route path="/tasks" element={<Tasks />} />
|
||||||
|
<Route path="/processes" element={<Processes />} />
|
||||||
|
<Route path="/task-logs" element={<TaskLogs />} />
|
||||||
|
<Route path="/auth/signout" element={<SignoutScreen />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,12 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router';
|
||||||
|
import * as Authentication from './Screens/Authentication';
|
||||||
|
// import { Home } from './Screens/Dashboard/Home';
|
||||||
|
|
||||||
|
// import { ChatList } from './Screens/Dashboard/Chat/ChatList';
|
||||||
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useServerSettings } from '@/state/useServerSettings';
|
import { useServerSettings } from '@/state/useServerSettings';
|
||||||
import { useInitialData } from '@/state/useInitialData';
|
import { useInitialData } from '@/state/useInitialData';
|
||||||
import { LandingPage, AuthLayout } from './Screens/LandingPage';
|
|
||||||
import { Home } from './Screens/Dashboard/Home';
|
|
||||||
import { ProfileSettings } from './Screens/Dashboard/Settings/ProfileSettings';
|
|
||||||
import { ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat';
|
|
||||||
import { Screen as ClaudeSessions } from 'plugins/ChatHistory/client';
|
|
||||||
import { Plans } from './Screens/Dashboard/Plans';
|
|
||||||
import { Skills } from './Screens/Dashboard/Skills';
|
|
||||||
import { Tasks } from './Screens/Dashboard/Tasks';
|
|
||||||
import { Processes } from './Screens/Dashboard/Processes';
|
|
||||||
import { TaskLogs } from './Screens/Dashboard/TaskLogs';
|
|
||||||
import { SignoutScreen } from './Screens/Dashboard/SignoutScreen';
|
|
||||||
import { Screen as Files } from 'plugins/FileBrowser/client';
|
|
||||||
import { Screen as Terminal } from 'plugins/Terminal/client';
|
|
||||||
import { AISettings } from './Screens/Dashboard/Settings/AISettings';
|
|
||||||
import { ServerSettings } from './Screens/Dashboard/Settings/ServerSettings';
|
|
||||||
import { ResourceSettings } from './Screens/Dashboard/Settings/ResourceSettings';
|
|
||||||
import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin';
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { isLoading, isAuthenticated } = useAuth();
|
const { isLoading, isAuthenticated } = useAuth();
|
||||||
@@ -30,42 +18,56 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<Routes>
|
<Authentication.AuthenticationLayout>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Routes>
|
||||||
<Route path="/auth/*" element={<AuthLayout />} />
|
<Route path="/" element={<Authentication.LandingPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/auth/verify" element={<Authentication.VerifyScreen />} />
|
||||||
</Routes>
|
<Route path="/auth/forgot-password" element={<Authentication.ForgotPassword />} />
|
||||||
)}
|
<Route path="/auth/reset-password" element={<Authentication.ResetPassword />} />
|
||||||
{isAuthenticated && !onboardingComplete && (
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<Routes>
|
</Routes>
|
||||||
<Route path="/onboarding-admin" element={<OnboardingAdmin />} />
|
</Authentication.AuthenticationLayout>
|
||||||
<Route path="/auth/signout" element={<SignoutScreen />} />
|
|
||||||
<Route path="*" element={<Navigate to="/onboarding-admin" replace />} />
|
|
||||||
</Routes>
|
|
||||||
)}
|
)}
|
||||||
|
{/* {isAuthenticated && !onboardingComplete && ( */}
|
||||||
|
{/* <Routes> */}
|
||||||
|
{/* <Route path="/onboarding-admin" element={<OnboardingAdmin />} /> */}
|
||||||
|
{/* <Route path="/auth/signout" element={<SignoutScreen />} /> */}
|
||||||
|
{/* <Route path="*" element={<Navigate to="/onboarding-admin" replace />} /> */}
|
||||||
|
{/* </Routes> */}
|
||||||
|
{/* )} */}
|
||||||
{isAuthenticated && onboardingComplete && (
|
{isAuthenticated && onboardingComplete && (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<HomeScreen />} />
|
||||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
{/* <Route path="/settings/profile" element={<ProfileSettings />} /> */}
|
||||||
<Route path="/settings/ai" element={<AISettings />} />
|
{/* <Route path="/settings/ai" element={<AISettings />} /> */}
|
||||||
<Route path="/settings/server" element={<ServerSettings />} />
|
{/* <Route path="/settings/server" element={<ServerSettings />} /> */}
|
||||||
<Route path="/settings/resources" element={<ResourceSettings />} />
|
{/* <Route path="/settings/resources" element={<ResourceSettings />} /> */}
|
||||||
<Route path="/chat" element={<ClaudeSessions />} />
|
{/* <Route path="/chat" element={<ChatList />} /> */}
|
||||||
<Route path="/chat/new" element={<NewChat />} />
|
{/* <Route path="/chat/new" element={<NewChat />} /> */}
|
||||||
<Route path="/chat/:sessionId" element={<ClaudeChat />} />
|
{/* <Route path="/chat/:sessionId" element={<ClaudeChat />} /> */}
|
||||||
<Route path="/chat/opencode/new" element={<OpenCodeChat />} />
|
{/* <Route path="/chat/opencode/new" element={<OpenCodeChat />} /> */}
|
||||||
<Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} />
|
{/* <Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} /> */}
|
||||||
<Route path="/files" element={<Files />} />
|
{/* <Route path="/files" element={<Files />} /> */}
|
||||||
<Route path="/terminal" element={<Terminal />} />
|
{/* <Route path="/terminal" element={<Terminal />} /> */}
|
||||||
<Route path="/plans" element={<Plans />} />
|
{/* <Route path="/plans" element={<Plans />} /> */}
|
||||||
<Route path="/skills" element={<Skills />} />
|
{/* <Route path="/skills" element={<Skills />} /> */}
|
||||||
<Route path="/tasks" element={<Tasks />} />
|
{/* <Route path="/tasks" element={<Tasks />} /> */}
|
||||||
<Route path="/processes" element={<Processes />} />
|
{/* <Route path="/processes" element={<Processes />} /> */}
|
||||||
<Route path="/task-logs" element={<TaskLogs />} />
|
{/* <Route path="/task-logs" element={<TaskLogs />} /> */}
|
||||||
<Route path="/auth/signout" element={<SignoutScreen />} />
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const HomeScreen = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<Link to="/auth/signout">Signout</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
|
||||||
|
export function ForgotPassword() {
|
||||||
|
const client = useClient('/api/auth');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const { formRef, state, isValid } = useForm<ForgotPasswordFormState>({}, (s) => !!s.email);
|
||||||
|
|
||||||
|
const handleSubmit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await client.post('/forgot-password', { email: state.email!.trim() });
|
||||||
|
setDone(true);
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||||
|
{done ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||||
|
<div className="text-sm text-duck-dark/60">
|
||||||
|
<p>
|
||||||
|
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||||
|
</p>
|
||||||
|
<p>Click it to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
||||||
|
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Email</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-duck-dark/60">
|
||||||
|
<p>
|
||||||
|
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForgotPasswordFormState = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useResetPassword } from './useResetPassword';
|
||||||
|
|
||||||
|
export function ResetPassword() {
|
||||||
|
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||||
|
const loading = tokenStatus === 'loading';
|
||||||
|
const invalid = tokenStatus === 'invalid';
|
||||||
|
const hideform = loading || invalid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loading && (
|
||||||
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
|
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invalid && (
|
||||||
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||||
|
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||||
|
<div className="text-duck-dark/60">Enter your new password</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<div className="grid md:flex gap-4">
|
||||||
|
<Label className="grid gap-2 flex-1">
|
||||||
|
<span className="text-duck-dark/70">Password</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Min 12 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2 flex-1">
|
||||||
|
<span className="text-duck-dark/70">Confirm Password</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ForgotPassword } from './ForgotPassword';
|
||||||
|
export { ResetPassword } from './ResetPassword';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useMounted } from 'hooks/useMounted';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
export const useResetPassword = () => {
|
||||||
|
const isMounted = useMounted();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useClient('/api/auth');
|
||||||
|
const { state, formRef, isValid } = useForm<ResetPasswordFormState>({}, validateForm);
|
||||||
|
|
||||||
|
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||||
|
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const verifyToken = async () => {
|
||||||
|
if (!verificationCode) return;
|
||||||
|
try {
|
||||||
|
const data = await client.post<{ ok: boolean }>('/verify-token', { verificationCode });
|
||||||
|
setTokenStatus(data.ok ? 'valid' : 'invalid');
|
||||||
|
} catch {
|
||||||
|
setTokenStatus('invalid');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
if (!verificationCode) {
|
||||||
|
setTokenStatus('invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyToken();
|
||||||
|
}, [isMounted]);
|
||||||
|
|
||||||
|
const handleSubmit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await client.post('/reset-password', {
|
||||||
|
password: state.password,
|
||||||
|
verificationCode,
|
||||||
|
});
|
||||||
|
toast.success('Password reset successfully. Please sign in.');
|
||||||
|
navigate('/');
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Failed to reset password. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { formRef, isValid, tokenStatus, isSubmitting, handleSubmit };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResetPasswordFormState = {
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
const validateForm = (state: Partial<ResetPasswordFormState>) => {
|
||||||
|
const { password, confirmPassword } = state;
|
||||||
|
if (!password || !confirmPassword) return false;
|
||||||
|
if (password !== confirmPassword) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
|
||||||
|
export function Bootstrap() {
|
||||||
|
const authClient = useClient('/api/auth');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const { formRef, state, isValid } = useForm({}, (state) => !!state.email);
|
||||||
|
|
||||||
|
const handleBootstrap = async () => {
|
||||||
|
const trimmed = state.email.trim();
|
||||||
|
if (!trimmed || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await authClient.post('/bootstrap', { email: trimmed });
|
||||||
|
setDone(true);
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Bootstrap failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{done ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||||
|
<div className="text-sm text-duck-dark/60">
|
||||||
|
<p>A verification link has been sent to <strong>{state.email}</strong>.</p>
|
||||||
|
<p>Click it to activate your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold text-duck-dark mb-2">Welcome, Admin</h2>
|
||||||
|
<p className="text-sm text-duck-dark/60 mb-6">Enter your email to create your administrator account.</p>
|
||||||
|
<form ref={formRef} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-lg border-2 border-duck-dark/20 text-sm text-duck-dark placeholder:!text-duck-dark/30 focus:outline-none focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleBootstrap}
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Sending...' : 'Bootstrap'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { OfficerSvgLogo } from '@/components/Logos';
|
||||||
|
import { useLandingPage } from '@/state/useLandingPage';
|
||||||
|
import { Bootstrap } from './Bootstrap';
|
||||||
|
import { Login } from './Login';
|
||||||
|
|
||||||
|
export function LandingPage() {
|
||||||
|
const { registrationOpen, isLoading } = useLandingPage();
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
|
<header className="pt-12 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
|
||||||
|
<div className="hidden md:block md:pt-28">
|
||||||
|
<OfficerSvgLogo size="2xl" />
|
||||||
|
</div>
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<OfficerSvgLogo size="lg" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-0 md:pb-12">
|
||||||
|
{registrationOpen ? <Bootstrap /> : <Login />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
|
||||||
|
const { signin } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await signin({ email: state.email!, password: state.password! });
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Login failed. Please try again.');
|
||||||
|
update({ ...state, password: '' });
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||||
|
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Email</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Password</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-duck-dark/60 grid gap-1">
|
||||||
|
{/* <p> */}
|
||||||
|
{/* Don't have an account?{' '} */}
|
||||||
|
{/* <Link to="/auth/register" className="text-duck-forest underline hover:text-duck-forest/80"> */}
|
||||||
|
{/* Register */}
|
||||||
|
{/* </Link> */}
|
||||||
|
{/* </p> */}
|
||||||
|
<p>
|
||||||
|
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginFormState = {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (state: Partial<LoginFormState>) => {
|
||||||
|
const { email, password } = state;
|
||||||
|
if (!email || !password) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './LandingPage';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Background } from './Background';
|
||||||
|
type AuthenticationLayoutProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
|
<section className="relative h-dvh snap-start overflow-hidden">
|
||||||
|
<Background />
|
||||||
|
<div className="absolute inset-0 z-520">
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { DuckAvatar } from "./DuckAvatar";
|
||||||
|
import { PixelGrid } from "@/components/PixelGrid";
|
||||||
|
import landscapebg from './landscape1.jpg';
|
||||||
|
|
||||||
|
export function Background() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${landscapebg})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PixelGrid />
|
||||||
|
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-2
@@ -72,12 +72,12 @@ export function DuckAvatar({ showDebug, fullControlMode, currentSection }: DuckA
|
|||||||
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
const heroClasses = 'fixed bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh]';
|
const heroClasses = 'fixed top-12 md:top-auto md:bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh]';
|
||||||
const miniClasses = 'fixed bottom-4 right-4 w-[20vh] h-[25vh]';
|
const miniClasses = 'fixed bottom-4 right-4 w-[20vh] h-[25vh]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${isHeroSection ? heroClasses : miniClasses} overflow-visible z-[510] pointer-events-none`}
|
className={`${isHeroSection ? heroClasses : miniClasses} overflow-visible z-1 pointer-events-none`}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
opacity: isLoaded ? 1 : 0,
|
opacity: isLoaded ? 1 : 0,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './AuthenticationLayout';
|
||||||
+1
@@ -14,3 +14,4 @@ export const SignoutScreen = () => {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
export const Verify = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useClient('/api/auth');
|
||||||
|
const { verify } = useAuth();
|
||||||
|
const { state, formRef, update, isValid } = useForm<VerifyFormState>({}, validateForm);
|
||||||
|
|
||||||
|
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||||
|
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const [resent, setResent] = useState(false);
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (!email || resending) return;
|
||||||
|
setResending(true);
|
||||||
|
try {
|
||||||
|
await client.post('/resend-verification', { email });
|
||||||
|
setResent(true);
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Failed to resend. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setResending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
// if (!open || !verificationCode) {
|
||||||
|
// if (!verificationCode) setTokenStatus('invalid');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// window.history.replaceState(null, '', '/auth/verify');
|
||||||
|
//
|
||||||
|
// client
|
||||||
|
// .post<{ email: string }>('/verify-token', { verificationCode })
|
||||||
|
// .then((data) => {
|
||||||
|
// setEmail(data.email);
|
||||||
|
// setTokenStatus('valid');
|
||||||
|
// })
|
||||||
|
// .catch(() => {
|
||||||
|
// // Try to decode email from expired token for resend
|
||||||
|
// try {
|
||||||
|
// const payload = JSON.parse(atob(verificationCode.split('.')[1]!));
|
||||||
|
// if (payload.email) setEmail(payload.email);
|
||||||
|
// } catch {
|
||||||
|
// // ignore
|
||||||
|
// }
|
||||||
|
// setTokenStatus('invalid');
|
||||||
|
// });
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Redirect after successful verification
|
||||||
|
useEffect(() => {
|
||||||
|
// if (!verified) return;
|
||||||
|
// const timer = setTimeout(() => navigate('/'), 3000);
|
||||||
|
// return () => clearTimeout(timer);
|
||||||
|
}, [verified]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-dvh">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Verify your account</div>
|
||||||
|
<div className="text-duck-dark/60">Enter your verification code</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||||
|
const { name, password, confirmPassword } = state;
|
||||||
|
if (!name || !password || !confirmPassword) return false;
|
||||||
|
if (password !== confirmPassword) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerifyFormState = {
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
};
|
||||||
|
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/Card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useVerifyScreen } from './useVerifyScreen';
|
||||||
|
|
||||||
|
export const VerifyScreen = () => {
|
||||||
|
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useVerifyScreen();
|
||||||
|
const loading = tokenStatus === 'loading';
|
||||||
|
const invalid = tokenStatus === 'invalid';
|
||||||
|
const hideform = loading || invalid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loading && (
|
||||||
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
|
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invalid && (
|
||||||
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||||
|
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-duck-dark text-2xl font-bold">Create Your Account</div>
|
||||||
|
<div className="text-duck-dark/60">Set up your administrator profile</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Email</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Name</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Your name"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Username</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="your-username"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="grid md:flex gap-4">
|
||||||
|
<Label className="grid gap-2 flex-1">
|
||||||
|
<span className="text-duck-dark/70">Password</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Min 12 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2 flex-1">
|
||||||
|
<span className="text-duck-dark/70">Confirm Password</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card >
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './VerifyScreen';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useMounted } from 'hooks/useMounted';
|
||||||
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
export const useVerifyScreen = () => {
|
||||||
|
const isMounted = useMounted();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useClient('/api/auth');
|
||||||
|
const { state, formRef, update, isValid } = useForm<VerifyFormState>({ email: '' }, validateForm);
|
||||||
|
|
||||||
|
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||||
|
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const verifyToken = async () => {
|
||||||
|
if (!verificationCode) return;
|
||||||
|
try {
|
||||||
|
const data = await client.post<{ ok: boolean; email: string }>('/verify-token', { verificationCode });
|
||||||
|
if (data.ok) {
|
||||||
|
setTokenStatus('valid');
|
||||||
|
requestAnimationFrame(() => update({ email: data.email }));
|
||||||
|
} else {
|
||||||
|
setTokenStatus('invalid');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTokenStatus('invalid');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
if (!verificationCode) {
|
||||||
|
setTokenStatus('invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyToken();
|
||||||
|
}, [isMounted]);
|
||||||
|
|
||||||
|
const handleSubmit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await client.post('/bootstrap', {
|
||||||
|
token: verificationCode,
|
||||||
|
name: state.name,
|
||||||
|
username: state.username,
|
||||||
|
password: state.password,
|
||||||
|
confirmPassword: state.confirmPassword,
|
||||||
|
});
|
||||||
|
toast.success('Account created. Please sign in.');
|
||||||
|
navigate('/auth/login');
|
||||||
|
} catch (ex) {
|
||||||
|
const error = ex as { message?: string };
|
||||||
|
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { formRef, state, isValid, tokenStatus, isSubmitting, handleSubmit };
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerifyFormState = {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||||
|
const { name, username, password, confirmPassword } = state;
|
||||||
|
if (!name || !username || !password || !confirmPassword) return false;
|
||||||
|
if (password !== confirmPassword) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { AuthenticationLayout } from './Layout';
|
||||||
|
import { LandingPage } from './LandingPage';
|
||||||
|
import { SignoutScreen } from './Signout';
|
||||||
|
import { VerifyScreen } from './VerifyScreen';
|
||||||
|
import { ForgotPassword, ResetPassword } from './ForgotPassword';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AuthenticationLayout,
|
||||||
|
LandingPage,
|
||||||
|
SignoutScreen,
|
||||||
|
VerifyScreen,
|
||||||
|
ForgotPassword,
|
||||||
|
ResetPassword,
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { DashboardLayout } from '@/Screens/Dashboard/Layout';
|
||||||
|
|
||||||
|
export const ChatList = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout mobileFull>
|
||||||
|
<h1>ChatList</h1>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { PixelGrid } from './components/PixelGrid';
|
import { PixelGrid } from '../../../../workspaces/components/PixelGrid';
|
||||||
import { DuckAvatar } from './components/DuckAvatar';
|
import { DuckAvatar } from './components/DuckAvatar';
|
||||||
import { SignupModal } from './components/SignupModal';
|
import { SignupModal } from './components/SignupModal';
|
||||||
import { LoginModal } from './components/LoginModal';
|
import { LoginModal } from './components/LoginModal';
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
export const useLandingPage = () => {
|
||||||
|
const apiClient = useClient();
|
||||||
|
|
||||||
|
const { data: setupData, isLoading } = useQuery({
|
||||||
|
queryKey: ['LANDING_PAGE_DATA'],
|
||||||
|
queryFn: () => apiClient.get<{ registrationOpen: boolean }>('/landing-page-data'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
registrationOpen: setupData?.registrationOpen,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "username" varchar(128);
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"id": "7c0d3634-8b25-41e9-b6f3-24fad2005532",
|
||||||
|
"prevId": "b9130f42-0743-4c4b-aaf1-dce52e708e22",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "bigserial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "user_roles",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'Member'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "user_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'Unverified'"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(128)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "varchar(128)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "varchar(512000)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password_changed_at": {
|
||||||
|
"name": "password_changed_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.passkeys": {
|
||||||
|
"name": "passkeys",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "bigserial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"name": "origin",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"credential_id": {
|
||||||
|
"name": "credential_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"counter": {
|
||||||
|
"name": "counter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.passkey_challenges": {
|
||||||
|
"name": "passkey_challenges",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"name": "origin",
|
||||||
|
"type": "varchar(512)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"challenge": {
|
||||||
|
"name": "challenge",
|
||||||
|
"type": "varchar(512)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_passkey_challenges_created_at": {
|
||||||
|
"name": "idx_passkey_challenges_created_at",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"passkey_challenges_email_origin_pk": {
|
||||||
|
"name": "passkey_challenges_email_origin_pk",
|
||||||
|
"columns": [
|
||||||
|
"email",
|
||||||
|
"origin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.token_blacklist": {
|
||||||
|
"name": "token_blacklist",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"jti": {
|
||||||
|
"name": "jti",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_token_blacklist_expires_at": {
|
||||||
|
"name": "idx_token_blacklist_expires_at",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "expires_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.user_roles": {
|
||||||
|
"name": "user_roles",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"Member",
|
||||||
|
"Admin",
|
||||||
|
"Owner",
|
||||||
|
"Super Admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.user_status": {
|
||||||
|
"name": "user_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"Unverified",
|
||||||
|
"Active",
|
||||||
|
"Prospect",
|
||||||
|
"Invited",
|
||||||
|
"Blocked",
|
||||||
|
"Banned",
|
||||||
|
"Deleted"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1770915839349,
|
"when": 1770915839349,
|
||||||
"tag": "0000_broken_gauntlet",
|
"tag": "0000_broken_gauntlet",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771340427681,
|
||||||
|
"tag": "0001_fat_blonde_phantom",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ export const Users = pgTable('users', {
|
|||||||
role: userRolesEnum('role').default(USER_ROLES[0]),
|
role: userRolesEnum('role').default(USER_ROLES[0]),
|
||||||
status: userStatusEnum('status').default(USER_STATUSES[0]),
|
status: userStatusEnum('status').default(USER_STATUSES[0]),
|
||||||
name: varchar('name', { length: 128 }),
|
name: varchar('name', { length: 128 }),
|
||||||
|
username: varchar('username', { length: 128 }),
|
||||||
avatar: varchar('avatar', { length: 512000 }),
|
avatar: varchar('avatar', { length: 512000 }),
|
||||||
passwordChangedAt: bigint('password_changed_at', { mode: 'number' }),
|
passwordChangedAt: bigint('password_changed_at', { mode: 'number' }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { resendVerificationHandler } from './resend-verification';
|
|||||||
import { changePasswordHandler } from './change-password';
|
import { changePasswordHandler } from './change-password';
|
||||||
import { forgotPasswordHandler } from './forgot-password';
|
import { forgotPasswordHandler } from './forgot-password';
|
||||||
import { resetPasswordHandler } from './reset-password';
|
import { resetPasswordHandler } from './reset-password';
|
||||||
|
import { bootstrapHandler } from './bootstrap';
|
||||||
import { usersMe } from './users-me';
|
import { usersMe } from './users-me';
|
||||||
import { passkeyRouter } from './passkey-router';
|
import { passkeyRouter } from './passkey-router';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ authRouter.get('/me', userMiddleware, usersMe);
|
|||||||
authRouter.post('/signin', signinRateLimiter, signinHandler);
|
authRouter.post('/signin', signinRateLimiter, signinHandler);
|
||||||
authRouter.post('/signout', userMiddleware, signoutHandler);
|
authRouter.post('/signout', userMiddleware, signoutHandler);
|
||||||
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
||||||
|
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
|
||||||
authRouter.post('/verify', verifyHandler);
|
authRouter.post('/verify', verifyHandler);
|
||||||
authRouter.post('/verify-token', verifyTokenHandler);
|
authRouter.post('/verify-token', verifyTokenHandler);
|
||||||
authRouter.post('/resend-verification', resendVerificationHandler);
|
authRouter.post('/resend-verification', resendVerificationHandler);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Handler } from 'hono';
|
||||||
|
import { sendMail } from 'emailer';
|
||||||
|
import { officerdb, count, Users } from 'officerdb';
|
||||||
|
import { sign, verify } from '@@/jwt';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { validatePassword } from './validate-password';
|
||||||
|
|
||||||
|
export const bootstrapHandler: Handler = async function (ctx) {
|
||||||
|
const body = ctx.get('body');
|
||||||
|
const origin = ctx.get('origin');
|
||||||
|
const token = body.token as string;
|
||||||
|
const email = body.email as string;
|
||||||
|
|
||||||
|
const result = await officerdb.select({ count: count() }).from(Users);
|
||||||
|
const userCount = result[0]?.count ?? 0;
|
||||||
|
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
throw errors.BAD_REQUEST('Invalid email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationCode = await sign({ email }, '24h');
|
||||||
|
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
template: 'VerifyAdmin',
|
||||||
|
subject: 'Verify your Officer account',
|
||||||
|
to: email,
|
||||||
|
data: { name: email, url },
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verify(token).catch(() => null) as { email: string } | null;
|
||||||
|
if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token');
|
||||||
|
|
||||||
|
const name = body.name as string;
|
||||||
|
const username = body.username as string;
|
||||||
|
const password = body.password as string;
|
||||||
|
const confirmPassword = body.confirmPassword as string;
|
||||||
|
|
||||||
|
if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required');
|
||||||
|
if (!username || !username.trim()) throw errors.BAD_REQUEST('Username is required');
|
||||||
|
validatePassword(password);
|
||||||
|
if (password !== confirmPassword) throw errors.BAD_REQUEST('Passwords do not match');
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
|
const insertedUsers = await officerdb.insert(Users).values({
|
||||||
|
email: payload.email,
|
||||||
|
password: passwordHash,
|
||||||
|
name: name.trim(),
|
||||||
|
username: username.trim(),
|
||||||
|
role: 'Super Admin',
|
||||||
|
status: 'Active',
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dbUser) return ctx.json({ ok: true });
|
if (!dbUser) return ctx.json({ ok: true });
|
||||||
const verificationCode = await sign({ id: dbUser.id, email }, '6h');
|
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
||||||
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
|
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ export const verifyTokenHandler: Handler = async function (ctx) {
|
|||||||
throw errors.BAD_REQUEST('Token is invalid or expired');
|
throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap token: has email but no id (user not yet created)
|
||||||
|
if (userInfo?.email && !userInfo?.id) {
|
||||||
|
return ctx.json({ ok: true, email: userInfo.email });
|
||||||
|
}
|
||||||
|
|
||||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||||
|
|
||||||
const user = await officerdb.query.Users.findFirst({
|
const user = await officerdb.query.Users.findFirst({
|
||||||
where: eq(Users.id, userInfo.id),
|
where: eq(Users.id, userInfo.id),
|
||||||
});
|
});
|
||||||
if (!user) throw errors.NOT_FOUND('User not found');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
|
||||||
|
// Reset-password tokens skip the verification status check
|
||||||
|
const isResetToken = (userInfo as Record<string, unknown>).purpose === 'reset-password';
|
||||||
|
if (!isResetToken && user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||||
|
|
||||||
return ctx.json({ ok: true, email: user.email });
|
return ctx.json({ ok: true, email: user.email });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { officerdb, count, Users } from 'officerdb';
|
||||||
|
|
||||||
|
export const landingPageDataRouter = createRouter();
|
||||||
|
|
||||||
|
landingPageDataRouter.get('/', async (ctx) => {
|
||||||
|
const result = await officerdb.select({ count: count() }).from(Users);
|
||||||
|
const userCount = result[0]?.count ?? 0;
|
||||||
|
return ctx.json({ registrationOpen: userCount === 0 });
|
||||||
|
});
|
||||||
@@ -23,12 +23,6 @@ serverSettingsRouter.route('/claude-code', claudeCodeRouter);
|
|||||||
serverSettingsRouter.route('/opencode', opencodeRouter);
|
serverSettingsRouter.route('/opencode', opencodeRouter);
|
||||||
serverSettingsRouter.route('/applications', applicationsRouter);
|
serverSettingsRouter.route('/applications', applicationsRouter);
|
||||||
|
|
||||||
serverSettingsRouter.get('/', async (ctx) => {
|
|
||||||
const result = await officerdb.select({ count: count() }).from(Users);
|
|
||||||
const userCount = result[0]?.count ?? 0;
|
|
||||||
return ctx.json({ registrationOpen: userCount === 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||||
const settings = await Bun.file(settingsPath).json();
|
const settings = await Bun.file(settingsPath).json();
|
||||||
return ctx.json(settings);
|
return ctx.json(settings);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRouter } from './create-router';
|
|||||||
import type { HonoVariables } from './create-router';
|
import type { HonoVariables } from './create-router';
|
||||||
import { authRouter } from './api/auth';
|
import { authRouter } from './api/auth';
|
||||||
import { serverSettingsRouter, settingsPath } from './api/server-settings/server-settings';
|
import { serverSettingsRouter, settingsPath } from './api/server-settings/server-settings';
|
||||||
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||||
import { updateUserHandler } from './api/users/update-user';
|
import { updateUserHandler } from './api/users/update-user';
|
||||||
import { plansRouter } from './api/plans/plans';
|
import { plansRouter } from './api/plans/plans';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
@@ -37,6 +38,7 @@ honoServer.use(
|
|||||||
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||||
honoServer.route('/api/auth', authRouter);
|
honoServer.route('/api/auth', authRouter);
|
||||||
honoServer.route('/api/server-settings', serverSettingsRouter);
|
honoServer.route('/api/server-settings', serverSettingsRouter);
|
||||||
|
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||||
|
|
||||||
const protectedRouter = createRouter();
|
const protectedRouter = createRouter();
|
||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type CardProps = ComponentPropsWithoutRef<'div'>;
|
|||||||
export const Card = ({ className, style, ...props }: CardProps) => {
|
export const Card = ({ className, style, ...props }: CardProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('rounded-xl border-2 border-duck-dark/30 shadow-lg', className)}
|
className={cn('rounded-xl border-2 border-duck-dark/30 shadow-lg p-5', className)}
|
||||||
style={cardStyle(style)}
|
style={cardStyle(style)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
const apiClient = useClient(apiUrl);
|
const apiClient = useClient(apiUrl);
|
||||||
const passKeyManager = usePasskeys();
|
const passKeyManager = usePasskeys();
|
||||||
|
|
||||||
const { data: setupData } = useQuery({
|
|
||||||
queryKey: ['AUTH_SETUP'],
|
|
||||||
queryFn: () => apiClient.get<{ registrationOpen: boolean }>('/server-settings'),
|
|
||||||
});
|
|
||||||
const registrationOpen = setupData?.registrationOpen ?? false;
|
|
||||||
|
|
||||||
const { isLoading } = useQuery<UserWithToken | null>({
|
const { isLoading } = useQuery<UserWithToken | null>({
|
||||||
queryKey: ['CURRENT_USER'],
|
queryKey: ['CURRENT_USER'],
|
||||||
@@ -115,7 +110,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
isLoading,
|
isLoading,
|
||||||
registrationOpen,
|
|
||||||
refreshUser,
|
refreshUser,
|
||||||
signin,
|
signin,
|
||||||
signout,
|
signout,
|
||||||
|
|||||||
Vendored
+20
@@ -1,3 +1,23 @@
|
|||||||
|
declare module '*.jpg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
type SelectOption = { value: number | string; label?: string; href?: string; hidden?: boolean };
|
type SelectOption = { value: number | string; label?: string; href?: string; hidden?: boolean };
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"ESNext",
|
"ESNext",
|
||||||
"DOM"
|
"DOM"
|
||||||
],
|
],
|
||||||
|
"types": [
|
||||||
|
"react",
|
||||||
|
"react-dom"
|
||||||
|
],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user