feat: member portal Sprint 5 — onboarding portal experience + restore Thread schema
- Redesign onboarding into a 5-step wizard: welcome → phone → consent → code → done - Progress bar, human-readable consent scopes (INGEST/ARCHIVE/REPLICATE/DISPLAY) with descriptions - Resend-code action, back navigation, indigo theme matching member portal - "Done" step introduces portal features (digests, events, privacy) with CTA - Onboard page shell: rounded card, policy footer - Restore Thread model + Message.expiresAt/quotedPlatformMsgId/threadId + RAW/DNC statuses to schema.prisma (wiped by prior reset; matches existing add_hybrid_ai_architecture migration) — unblocks ThreadsModule registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,7 @@ model Tenant {
|
|||||||
digestConfig DigestConfig?
|
digestConfig DigestConfig?
|
||||||
digests Digest[]
|
digests Digest[]
|
||||||
events Event[]
|
events Event[]
|
||||||
|
threads Thread[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdminRole {
|
enum AdminRole {
|
||||||
@@ -235,6 +236,7 @@ model Group {
|
|||||||
consents ConsentRecord[]
|
consents ConsentRecord[]
|
||||||
claimTokens GroupClaimToken[]
|
claimTokens GroupClaimToken[]
|
||||||
groupAccesses GroupAccess[]
|
groupAccesses GroupAccess[]
|
||||||
|
threads Thread[]
|
||||||
|
|
||||||
@@unique([platform, platformId])
|
@@unique([platform, platformId])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@ -247,37 +249,62 @@ model Group {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
platform String
|
platform String
|
||||||
platformMsgId String
|
platformMsgId String
|
||||||
sourceGroupId String
|
sourceGroupId String
|
||||||
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||||
senderJid String
|
senderJid String
|
||||||
senderName String?
|
senderName String?
|
||||||
senderTowerUserId String?
|
senderTowerUserId String?
|
||||||
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
|
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
|
||||||
content String
|
content String
|
||||||
mediaUrl String?
|
mediaUrl String?
|
||||||
tags String[]
|
tags String[]
|
||||||
status MessageStatus @default(PENDING)
|
status MessageStatus @default(PENDING)
|
||||||
createdAt DateTime @default(now())
|
expiresAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
quotedPlatformMsgId String?
|
||||||
|
threadId String?
|
||||||
|
thread Thread? @relation(fields: [threadId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
approval Approval?
|
approval Approval?
|
||||||
|
|
||||||
@@unique([platform, platformMsgId])
|
@@unique([platform, platformMsgId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([senderTowerUserId])
|
@@index([senderTowerUserId])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([threadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MessageStatus {
|
enum MessageStatus {
|
||||||
|
RAW
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
REJECTED
|
REJECTED
|
||||||
DISTRIBUTED
|
DISTRIBUTED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
|
DNC
|
||||||
|
}
|
||||||
|
|
||||||
|
model Thread {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
sourceGroupId String
|
||||||
|
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||||
|
lastActivityAt DateTime @default(now())
|
||||||
|
messageCount Int @default(0)
|
||||||
|
topic String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([sourceGroupId, lastActivityAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Approval {
|
model Approval {
|
||||||
|
|||||||
@@ -4,14 +4,49 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
token: string;
|
token: string;
|
||||||
|
groupName: string;
|
||||||
|
tenantName: string;
|
||||||
defaultScopes: string[];
|
defaultScopes: string[];
|
||||||
defaultRetentionDays: number;
|
defaultRetentionDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'phone' | 'code' | 'done';
|
type Step = 'welcome' | 'phone' | 'consent' | 'code' | 'done';
|
||||||
|
|
||||||
export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) {
|
const STEP_ORDER: Step[] = ['welcome', 'phone', 'consent', 'code', 'done'];
|
||||||
const [step, setStep] = useState<Step>('phone');
|
|
||||||
|
interface ScopeMeta {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_META: ScopeMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'INGEST',
|
||||||
|
label: 'Read group messages',
|
||||||
|
description: 'Let TOWER read messages you send here so they can be organised and surfaced.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ARCHIVE',
|
||||||
|
label: 'Save to searchable archive',
|
||||||
|
description: 'Keep a record of relevant messages so members can find them later.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'REPLICATE',
|
||||||
|
label: 'Share across connected groups',
|
||||||
|
description: 'Allow approved messages to be forwarded to connected sister groups.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'DISPLAY',
|
||||||
|
label: 'Show in portal & digests',
|
||||||
|
description: 'Let your contributions appear in the member portal and daily digests.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
|
||||||
|
|
||||||
|
export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) {
|
||||||
|
const [step, setStep] = useState<Step>('welcome');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [challengeId, setChallengeId] = useState<string | null>(null);
|
const [challengeId, setChallengeId] = useState<string | null>(null);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
@@ -20,11 +55,14 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stepIndex = STEP_ORDER.indexOf(step);
|
||||||
|
const progressPct = (stepIndex / (STEP_ORDER.length - 1)) * 100;
|
||||||
|
|
||||||
async function requestOtp() {
|
async function requestOtp() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'}/public/auth/request-otp`, {
|
const res = await fetch(`${API_BASE}/public/auth/request-otp`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ onboardingToken: token, phone }),
|
body: JSON.stringify({ onboardingToken: token, phone }),
|
||||||
@@ -36,7 +74,7 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
|
|||||||
}
|
}
|
||||||
const data = (await res.json()) as { challengeId: string };
|
const data = (await res.json()) as { challengeId: string };
|
||||||
setChallengeId(data.challengeId);
|
setChallengeId(data.challengeId);
|
||||||
setStep('code');
|
setStep('consent');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -49,14 +87,7 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
|
|||||||
const res = await fetch('/api/onboard/verify-otp', {
|
const res = await fetch('/api/onboard/verify-otp', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ onboardingToken: token, challengeId, phone, code, scopes, retentionDays }),
|
||||||
onboardingToken: token,
|
|
||||||
challengeId,
|
|
||||||
phone,
|
|
||||||
code,
|
|
||||||
scopes,
|
|
||||||
retentionDays,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||||
@@ -69,95 +100,222 @@ export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'done') {
|
function toggleScope(key: string, checked: boolean) {
|
||||||
return (
|
setScopes((prev) => (checked ? [...prev, key] : prev.filter((s) => s !== key)));
|
||||||
<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 (
|
||||||
return (
|
<div>
|
||||||
<div className="space-y-3">
|
{/* Progress bar */}
|
||||||
<label className="block text-sm">
|
{step !== 'welcome' && step !== 'done' && (
|
||||||
<span className="font-medium">Your WhatsApp phone number</span>
|
<div className="mb-6">
|
||||||
|
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-indigo-600 transition-all duration-300"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">Step {stepIndex} of 3</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'welcome' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-indigo-100 flex items-center justify-center text-2xl">
|
||||||
|
👋
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Welcome to {groupName}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Managed by {tenantName}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
|
TOWER keeps your community's important messages organised, searchable, and shareable —
|
||||||
|
while keeping you in control of your data. Let's get you set up. It takes about a minute.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('phone')}
|
||||||
|
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'phone' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Your WhatsApp number</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">We'll send a 6-digit code to this number on WhatsApp.</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
placeholder="+1 234 567 8900"
|
placeholder="+91 98765 43210"
|
||||||
className="mt-1 w-full border rounded px-3 py-2"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={requestOtp}
|
onClick={() => setStep('welcome')}
|
||||||
disabled={busy || phone.length < 6}
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
>
|
||||||
>
|
Back
|
||||||
Send verification code
|
</button>
|
||||||
</button>
|
<button
|
||||||
<p className="text-xs text-gray-500">
|
type="button"
|
||||||
We'll DM a 6-digit code to your WhatsApp.
|
onClick={requestOtp}
|
||||||
</p>
|
disabled={busy || phone.replace(/\D/g, '').length < 8}
|
||||||
</div>
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
);
|
>
|
||||||
}
|
{busy ? 'Sending…' : 'Send code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{step === 'consent' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<label className="block text-sm">
|
<div>
|
||||||
<span className="font-medium">Verification code</span>
|
<h2 className="text-base font-semibold text-gray-900">What you're agreeing to</h2>
|
||||||
<input
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
type="text"
|
You stay in control — change or revoke any of these anytime from your portal.
|
||||||
inputMode="numeric"
|
</p>
|
||||||
pattern="[0-9]{6}"
|
</div>
|
||||||
value={code}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setCode(e.target.value)}
|
{SCOPE_META.map((s) => {
|
||||||
placeholder="123456"
|
const checked = scopes.includes(s.key);
|
||||||
className="mt-1 w-full border rounded px-3 py-2"
|
return (
|
||||||
/>
|
<label
|
||||||
</label>
|
key={s.key}
|
||||||
<fieldset className="text-sm">
|
className={`flex gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||||
<legend className="font-medium mb-1">Scopes</legend>
|
checked ? 'border-indigo-300 bg-indigo-50/50' : 'border-gray-200 hover:bg-gray-50'
|
||||||
{['INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY'].map((s) => (
|
}`}
|
||||||
<label key={s} className="block">
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={scopes.includes(s)}
|
checked={checked}
|
||||||
onChange={(e) =>
|
onChange={(e) => toggleScope(s.key, e.target.checked)}
|
||||||
setScopes((prev) => (e.target.checked ? [...prev, s] : prev.filter((x) => x !== s)))
|
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 shrink-0"
|
||||||
}
|
/>
|
||||||
/>{' '}
|
<span>
|
||||||
{s}
|
<span className="block text-sm font-medium text-gray-900">{s.label}</span>
|
||||||
|
<span className="block text-xs text-gray-500 mt-0.5">{s.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-gray-700">Keep my messages for</span>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
||||||
|
className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">days</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
</fieldset>
|
<div className="flex gap-2">
|
||||||
<label className="block text-sm">
|
<button
|
||||||
<span className="font-medium">Retention (days)</span>
|
type="button"
|
||||||
<input
|
onClick={() => setStep('phone')}
|
||||||
type="number"
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
||||||
min={1}
|
>
|
||||||
max={3650}
|
Back
|
||||||
value={retentionDays}
|
</button>
|
||||||
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
<button
|
||||||
className="mt-1 w-32 border rounded px-3 py-2"
|
type="button"
|
||||||
/>
|
onClick={() => { setError(null); setStep('code'); }}
|
||||||
</label>
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
>
|
||||||
<button
|
Continue
|
||||||
type="button"
|
</button>
|
||||||
onClick={verifyOtp}
|
</div>
|
||||||
disabled={busy || code.length < 6}
|
</div>
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
)}
|
||||||
>
|
|
||||||
Verify and join
|
{step === 'code' && (
|
||||||
</button>
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Enter your code</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
We sent a 6-digit code to <span className="font-medium text-gray-700">{phone}</span> on WhatsApp.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
placeholder="000000"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={requestOtp}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs text-indigo-600 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Didn't get it? Resend code
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('consent')}
|
||||||
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={verifyOtp}
|
||||||
|
disabled={busy || code.length < 6}
|
||||||
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{busy ? 'Verifying…' : 'Verify & join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'done' && (
|
||||||
|
<div className="space-y-5 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center text-2xl mx-auto">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">You're all set!</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Welcome to {groupName}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-left bg-gray-50 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">In your portal you can</p>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1.5">
|
||||||
|
<li>📋 Read daily digests of what matters</li>
|
||||||
|
<li>📅 RSVP to community events</li>
|
||||||
|
<li>🔒 Manage your privacy & consent anytime</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/my"
|
||||||
|
className="block w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go to my portal →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,25 +42,27 @@ export default async function OnboardPage({
|
|||||||
|
|
||||||
if (error || !info) {
|
if (error || !info) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
|
<div className="max-w-md mx-auto mt-12 p-6 rounded-2xl border border-red-200 bg-red-50">
|
||||||
<h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1>
|
<h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1>
|
||||||
<p className="text-sm text-red-700">{error ?? 'Unknown error'}</p>
|
<p className="text-sm text-red-700 mt-1">{error ?? 'Unknown error'}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
|
<div className="max-w-md mx-auto mt-12">
|
||||||
<h1 className="text-xl font-semibold mb-2">Join {info.groupName}</h1>
|
<div className="p-6 sm:p-7 rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
<p className="text-sm text-gray-600 mb-1">Managed by {info.tenantName}</p>
|
<OnboardingForm
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
token={token}
|
||||||
Policy version: {info.policyVersion} · Default retention: {info.defaultRetentionDays} days
|
groupName={info.groupName}
|
||||||
|
tenantName={info.tenantName}
|
||||||
|
defaultScopes={info.defaultScopes}
|
||||||
|
defaultRetentionDays={info.defaultRetentionDays}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-4">
|
||||||
|
Policy {info.policyVersion} · Your data, your control
|
||||||
</p>
|
</p>
|
||||||
<OnboardingForm
|
|
||||||
token={token}
|
|
||||||
defaultScopes={info.defaultScopes}
|
|
||||||
defaultRetentionDays={info.defaultRetentionDays}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user