good forst commit
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
defaultScopes: string[];
|
||||
defaultRetentionDays: number;
|
||||
}
|
||||
|
||||
type Step = 'phone' | 'code' | 'done';
|
||||
|
||||
export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) {
|
||||
const [step, setStep] = useState<Step>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [challengeId, setChallengeId] = useState<string | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [retentionDays, setRetentionDays] = useState(defaultRetentionDays);
|
||||
const [scopes, setScopes] = useState<string[]>(defaultScopes);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function requestOtp() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`${process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'}/public/auth/request-otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ onboardingToken: token, phone }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
setError(body.message ?? 'Failed to send code');
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { challengeId: string };
|
||||
setChallengeId(data.challengeId);
|
||||
setStep('code');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOtp() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/onboard/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
onboardingToken: token,
|
||||
challengeId,
|
||||
phone,
|
||||
code,
|
||||
scopes,
|
||||
retentionDays,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
setError(body.message ?? 'Verification failed');
|
||||
return;
|
||||
}
|
||||
setStep('done');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'done') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-green-700 mb-4">You're verified. Your session is set.</p>
|
||||
<a href="/my" className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm">
|
||||
Go to your portal →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'phone') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Your WhatsApp phone number</span>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 234 567 8900"
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestOtp}
|
||||
disabled={busy || phone.length < 6}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
Send verification code
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">
|
||||
We'll DM a 6-digit code to your WhatsApp.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Verification code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="123456"
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="text-sm">
|
||||
<legend className="font-medium mb-1">Scopes</legend>
|
||||
{['INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY'].map((s) => (
|
||||
<label key={s} className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(s)}
|
||||
onChange={(e) =>
|
||||
setScopes((prev) => (e.target.checked ? [...prev, s] : prev.filter((x) => x !== s)))
|
||||
}
|
||||
/>{' '}
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Retention (days)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
||||
className="mt-1 w-32 border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={verifyOtp}
|
||||
disabled={busy || code.length < 6}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
Verify and join
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { OnboardingForm } from './OnboardingForm';
|
||||
|
||||
interface PublicOnboardInfo {
|
||||
groupName: string;
|
||||
tenantName: string;
|
||||
policyVersion: string;
|
||||
defaultScopes: string[];
|
||||
defaultRetentionDays: number;
|
||||
}
|
||||
|
||||
export default async function OnboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const { token } = await searchParams;
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
||||
<h1 className="text-lg font-semibold text-red-800">Invalid link</h1>
|
||||
<p className="text-sm text-red-700">This onboarding link is missing the token parameter.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let info: PublicOnboardInfo | null = null;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env['API_URL'] ?? 'http://localhost:3001'}/public/onboard/${encodeURIComponent(token)}`,
|
||||
{ headers: { Accept: 'application/json' }, cache: 'no-store' },
|
||||
);
|
||||
if (res.ok) {
|
||||
info = (await res.json()) as PublicOnboardInfo;
|
||||
} else {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
error = body.message ?? `Onboarding link rejected (${res.status})`;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Network error';
|
||||
}
|
||||
|
||||
if (error || !info) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
||||
<h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1>
|
||||
<p className="text-sm text-red-700">{error ?? 'Unknown error'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
|
||||
<h1 className="text-xl font-semibold mb-2">Join {info.groupName}</h1>
|
||||
<p className="text-sm text-gray-600 mb-1">Managed by {info.tenantName}</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Policy version: {info.policyVersion} · Default retention: {info.defaultRetentionDays} days
|
||||
</p>
|
||||
<OnboardingForm
|
||||
token={token}
|
||||
defaultScopes={info.defaultScopes}
|
||||
defaultRetentionDays={info.defaultRetentionDays}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user