chore: standardize security and quality foundations
This commit is contained in:
+154
-117
@@ -1,139 +1,146 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import AuthLayout from "../ui/AuthLayout";
|
||||
import { clearToken, getApiBase, setToken } from "../lib/auth";
|
||||
|
||||
type InviteInfo = {
|
||||
code: string
|
||||
email_bound?: boolean
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
is_expired?: boolean
|
||||
is_usable?: boolean
|
||||
expires_at?: string | null
|
||||
max_uses?: number | null
|
||||
use_count?: number | null
|
||||
remaining_uses?: number | null
|
||||
code: string;
|
||||
email_bound?: boolean;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
enabled: boolean;
|
||||
is_expired?: boolean;
|
||||
is_usable?: boolean;
|
||||
expires_at?: string | null;
|
||||
max_uses?: number | null;
|
||||
use_count?: number | null;
|
||||
remaining_uses?: number | null;
|
||||
profile?: {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
} | null
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return 'Never'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.valueOf())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
function SignupPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
|
||||
const [invite, setInvite] = useState<InviteInfo | null>(null)
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [inviteCode, setInviteCode] = useState(searchParams.get("code") ?? "");
|
||||
const [invite, setInvite] = useState<InviteInfo | null>(null);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
|
||||
}, [invite, email, username, password, loading, inviteLoading])
|
||||
return Boolean(
|
||||
invite?.is_usable &&
|
||||
(invite.email_bound || email.trim()) &&
|
||||
username.trim() &&
|
||||
password &&
|
||||
!loading &&
|
||||
!inviteLoading,
|
||||
);
|
||||
}, [invite, email, username, password, loading, inviteLoading]);
|
||||
|
||||
const lookupInvite = async (code: string) => {
|
||||
const trimmed = code.trim()
|
||||
const lookupInvite = useCallback(async (code: string) => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
setInvite(null)
|
||||
return
|
||||
setInvite(null);
|
||||
return;
|
||||
}
|
||||
setInviteLoading(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setInviteLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
|
||||
const baseUrl = getApiBase();
|
||||
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Invite not found')
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Invite not found");
|
||||
}
|
||||
const data = await response.json()
|
||||
setInvite(data?.invite ?? null)
|
||||
setStatus('Invite loaded.')
|
||||
const data = await response.json();
|
||||
setInvite(data?.invite ?? null);
|
||||
setStatus("Invite loaded.");
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setInvite(null)
|
||||
setError('Invite code not found or unavailable.')
|
||||
console.error(err);
|
||||
setInvite(null);
|
||||
setError("Invite code not found or unavailable.");
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
setInviteLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initialCode = searchParams.get('code') ?? ''
|
||||
const initialCode = searchParams.get("code") ?? "";
|
||||
if (initialCode) {
|
||||
setInviteCode(initialCode)
|
||||
void lookupInvite(initialCode)
|
||||
setInviteCode(initialCode);
|
||||
void lookupInvite(initialCode);
|
||||
}
|
||||
}, [searchParams])
|
||||
}, [lookupInvite, searchParams]);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (!inviteCode.trim()) {
|
||||
setError('Invite code is required.')
|
||||
return
|
||||
setError("Invite code is required.");
|
||||
return;
|
||||
}
|
||||
if (!invite?.is_usable) {
|
||||
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
|
||||
return
|
||||
setError("Invite is not usable. Refresh invite details or ask an admin for a new code.");
|
||||
return;
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
try {
|
||||
clearToken()
|
||||
const baseUrl = getApiBase()
|
||||
clearToken();
|
||||
const baseUrl = getApiBase();
|
||||
const response = await fetch(`${baseUrl}/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
username: username.trim(),
|
||||
...(!invite.email_bound ? { email: email.trim() } : {}),
|
||||
password,
|
||||
}),
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Sign-up failed')
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Sign-up failed");
|
||||
}
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
window.location.href = '/welcome'
|
||||
return
|
||||
setToken("cookie");
|
||||
window.location.href = "/welcome";
|
||||
return;
|
||||
}
|
||||
throw new Error('Sign-up did not complete')
|
||||
throw new Error("Sign-up did not complete");
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Unable to create account.");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||
@@ -143,7 +150,11 @@ function SignupPageContent() {
|
||||
<div className="invite-lookup-row">
|
||||
<input
|
||||
value={inviteCode}
|
||||
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
|
||||
onChange={(e) => {
|
||||
setInviteCode(e.target.value);
|
||||
setInvite(null);
|
||||
setEmail("");
|
||||
}}
|
||||
placeholder="Paste your invite code"
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
@@ -153,38 +164,50 @@ function SignupPageContent() {
|
||||
disabled={inviteLoading}
|
||||
onClick={() => void lookupInvite(inviteCode)}
|
||||
>
|
||||
{inviteLoading ? 'Checking…' : 'Check invite'}
|
||||
{inviteLoading ? "Checking…" : "Check invite"}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{invite && (
|
||||
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
|
||||
<div className={`invite-summary ${invite.is_usable ? "" : "is-disabled"}`}>
|
||||
<div className="invite-summary-row">
|
||||
<strong>{invite.label || invite.code}</strong>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Ready' : 'Unavailable'}
|
||||
<span className={`small-pill ${invite.is_usable ? "" : "is-muted"}`}>
|
||||
{invite.is_usable ? "Ready" : "Unavailable"}
|
||||
</span>
|
||||
</div>
|
||||
{invite.description && <p>{invite.description}</p>}
|
||||
<details className="auth-invite-details"><summary>Invite details</summary><div className="admin-meta-row">
|
||||
<span>Code: {invite.code}</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
||||
<span>Profile: {invite.profile?.name || 'None'}</span>
|
||||
</div></details>
|
||||
<details className="auth-invite-details">
|
||||
<summary>Invite details</summary>
|
||||
<div className="admin-meta-row">
|
||||
<span>Code: {invite.code}</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Remaining uses: {invite.remaining_uses ?? "Unlimited"}</span>
|
||||
<span>Profile: {invite.profile?.name || "None"}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{invite?.email_bound ? <p className="account-hint">Your account will use the email address this invitation was sent to. This invitation can be used once.</p> : <label>
|
||||
Email address
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" />
|
||||
</label>}
|
||||
{invite?.email_bound ? (
|
||||
<p className="account-hint">
|
||||
Your account will use the email address this invitation was sent to. This invitation can be used once.
|
||||
</p>
|
||||
) : (
|
||||
<label>
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
@@ -204,25 +227,39 @@ function SignupPageContent() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||
{error && (
|
||||
<div className="account-notice is-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{status && (
|
||||
<div className="account-notice is-status" role="status">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" className="account-primary" disabled={!canSubmit}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
{loading ? "Creating account…" : "Create account"}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push("/login")}>
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up…</p></AuthLayout>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||
<p role="status">Loading sign-up…</p>
|
||||
</AuthLayout>
|
||||
}
|
||||
>
|
||||
<SignupPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user