mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
signup codes: admin-issued invite codes + site /join redemption
While account creation is disabled, the only way in is now an admin-issued single-use signup code (Discord-invite style), redeemed on the public site's /join page. Because the 2020 watch login is device-id-bound with no code field, the flow captures it server-side instead: a new device refused at the signups-disabled gate (PlatformLogin / account create) is stashed in a PendingDevices table keyed by the real client IP (CF-Connecting-IP aware). The /join page then shows the player the device their own client just reported, they enter their code + username, and the account is minted bound to that device — so the next launch logs straight in via the existing device-id path. No watch changes. - SignupCodeEntity (code, descriptor, optional expiry, redeemer) + PendingDeviceEntity; idempotent CREATE TABLE in DatabaseBootstrap so both providers pick them up without a new migration. - SignupCodeService: generate / list / revoke / redeem + pending-device capture, single-use + expiry + revoke validation. - Admin: GET/POST signup-codes + revoke endpoints and a Signup codes admin-ui page (generate with descriptor/expiry, status table, revoke). - Public: anonymous /api/site/v1/join pending-devices + redeem, and a /join site page (+ header link). - README documents the flow + the post-Initial table-create approach.
This commit is contained in:
@@ -21,6 +21,7 @@ import { Gift } from './pages/Gift';
|
||||
import { Passwords } from './pages/Passwords';
|
||||
import { Grants } from './pages/Grants';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { SignupCodes } from './pages/SignupCodes';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -54,6 +55,7 @@ export default function App() {
|
||||
<Route path="passwords" element={<Passwords />} />
|
||||
<Route path="grants" element={<Grants />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="signup-codes" element={<SignupCodes />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -43,6 +43,7 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i
|
||||
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
|
||||
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
|
||||
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
|
||||
{ to: '/signup-codes', label: 'Signup codes', icon: <KeyRound />, new: true },
|
||||
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Plus, RefreshCw } from '../components/Icons';
|
||||
|
||||
interface SignupCode {
|
||||
id: number;
|
||||
code: string;
|
||||
descriptor: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
revoked: boolean;
|
||||
redeemedAt: string | null;
|
||||
redeemedByPlayerId: number | null;
|
||||
redeemedByUsername: string | null;
|
||||
status: 'active' | 'redeemed' | 'revoked' | 'expired';
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<SignupCode['status'], string> = {
|
||||
active: 'badge-online',
|
||||
redeemed: 'badge-admin',
|
||||
revoked: 'badge-banned',
|
||||
expired: 'badge-neutral',
|
||||
};
|
||||
|
||||
export function SignupCodes() {
|
||||
const [codes, setCodes] = useState<SignupCode[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [descriptor, setDescriptor] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setCodes(await get<SignupCode[]>('/signup-codes'));
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const created = await api<{ code: string }>('/signup-codes', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
Descriptor: descriptor.trim(),
|
||||
ExpiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
},
|
||||
});
|
||||
setDescriptor('');
|
||||
setExpiresAt('');
|
||||
toast.push(`Code ${created.code} created.`, 'success');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: number, code: string) => {
|
||||
if (!confirm(`Revoke code ${code}? It can no longer be redeemed.`)) return;
|
||||
try {
|
||||
await api(`/signup-codes/${id}/revoke`, { method: 'POST' });
|
||||
toast.push('Code revoked.', 'success');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.push('Code copied.', 'success');
|
||||
} catch {
|
||||
toast.push('Copy failed — select it manually.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Signup codes"
|
||||
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
|
||||
actions={
|
||||
<button onClick={load} className="btn-secondary text-xs">
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||
)}
|
||||
|
||||
<div className="card !p-5 max-w-2xl">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Generate a code</h2>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label className="block">
|
||||
<span className="label">Descriptor (who is this for?)</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={descriptor}
|
||||
onChange={(e) => setDescriptor(e.target.value)}
|
||||
placeholder="e.g. Phil's friend Dana"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Expires (optional)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input mt-1"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button onClick={generate} disabled={busy} className="btn-primary text-xs">
|
||||
<Plus />
|
||||
{busy ? 'Generating…' : 'Generate code'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card !p-0 mt-5 overflow-hidden">
|
||||
{!codes && !err && <div className="p-6 text-center text-xs text-ink-400">Loading…</div>}
|
||||
{codes && codes.length === 0 && (
|
||||
<div className="p-6 text-center text-xs text-ink-400">No codes yet — generate one above.</div>
|
||||
)}
|
||||
{codes && codes.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-[11px] uppercase tracking-wide text-ink-500 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Code</th>
|
||||
<th className="px-4 py-2">Descriptor</th>
|
||||
<th className="px-4 py-2">Status</th>
|
||||
<th className="px-4 py-2">Redeemed by</th>
|
||||
<th className="px-4 py-2">Expires</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{codes.map((c) => (
|
||||
<tr key={c.id} className="border-b border-ink-900/60">
|
||||
<td className="px-4 py-2 font-mono text-ink-100">{c.code}</td>
|
||||
<td className="px-4 py-2 text-ink-300">{c.descriptor || <span className="text-ink-600">—</span>}</td>
|
||||
<td className="px-4 py-2"><span className={STATUS_BADGE[c.status]}>{c.status}</span></td>
|
||||
<td className="px-4 py-2 text-ink-300">
|
||||
{c.redeemedByUsername
|
||||
? <span>{c.redeemedByUsername} <span className="text-ink-600">({c.redeemedByPlayerId})</span></span>
|
||||
: <span className="text-ink-600">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-400 text-xs">
|
||||
{c.expiresAt ? new Date(c.expiresAt).toLocaleString() : <span className="text-ink-600">never</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => copy(c.code)} className="btn-ghost !px-2 !py-1 text-xs">Copy</button>
|
||||
{c.status === 'active' && (
|
||||
<button onClick={() => revoke(c.id, c.code)} className="btn-ghost !px-2 !py-1 text-xs text-danger">Revoke</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user