Add guest auth and save-account flow

Introduce anonymous sign-in with in-app saving, guest recovery warnings, paywall prompts, and admin guest labeling.
This commit is contained in:
jjbrunton committed 2026-01-17 17:55:06 +00:00
1 parent ce68d57a49
commit 349f460e15
14 files changed
+1236 -176

No files matched your search

+11 -2
View File
@@ -539,10 +539,19 @@ export function UserDetailPage() {
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
</div>
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
<div className="text-muted-foreground mb-4 flex items-center gap-2">
{profile.email ? (
<span>{profile.email}</span>
) : (
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">Guest</Badge>
)}
{!profile.email && (
<span className="text-xs">Not recoverable unless saved in-app</span>
)}
</div>
<div className="flex gap-6 text-sm">
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : profile.couple_id ? <div className="text-amber-600 dark:text-amber-400 flex items-center gap-1"><Users className="h-4 w-4" />Waiting for partner</div> : <div className="text-muted-foreground">Not paired</div>}
</div>
</div>
</div></CardContent></Card>
+45 -15
View File
@@ -17,7 +17,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning, Hourglass } from 'lucide-react';
import { format } from 'date-fns';
interface Profile {
@@ -32,6 +32,7 @@ interface Profile {
email_confirmed_at: string | null;
storage_bytes?: number;
partner_is_premium?: boolean;
partner_count?: number;
}
function formatBytes(bytes: number): string {
@@ -87,11 +88,13 @@ export function UsersPage() {
}
});
// Merge storage data and partner premium status with profiles
// Merge storage data, partner premium status, and partner count with profiles
const profilesWithExtras = profilesData.map(profile => {
// Check if partner has premium
// Check if partner has premium and count partners in couple
let partnerIsPremium = false;
let partnerCount = 0;
if (profile.couple_id && couplePartners[profile.couple_id]) {
partnerCount = couplePartners[profile.couple_id].length;
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
partnerIsPremium = partner?.is_premium || false;
}
@@ -99,6 +102,7 @@ export function UsersPage() {
...profile,
storage_bytes: storageMap[profile.id] || 0,
partner_is_premium: partnerIsPremium,
partner_count: partnerCount,
};
});
@@ -126,9 +130,12 @@ export function UsersPage() {
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
}, []),
insertToast: {
insertToast: {
enabled: true,
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
message: (payload) => {
const label = payload.name || payload.email || 'Guest';
return payload.email ? `New user registered: ${label}` : `New guest user: ${label}`;
},
type: 'success',
},
debounceMs: 1000, // Debounce to avoid excessive refetches
@@ -191,7 +198,15 @@ export function UsersPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground text-sm">
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
{profiles.length} account{profiles.length !== 1 ? 's' : ''}
{(() => {
const guestCount = profiles.filter(p => !p.email).length;
const savedCount = profiles.length - guestCount;
if (guestCount === 0) return null;
return (
<span> • {savedCount} saved • {guestCount} guest</span>
);
})()}
</p>
</div>
<div className="relative w-full sm:w-64">
@@ -245,11 +260,19 @@ export function UsersPage() {
</TableCell>
<TableCell className="text-muted-foreground">
<div className="flex items-center gap-1.5">
{profile.email || '—'}
{profile.email && !profile.email_confirmed_at && (
<span title="Email not verified">
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
</span>
{profile.email ? (
<>
{profile.email}
{!profile.email_confirmed_at && (
<span title="Email not verified">
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
</span>
)}
</>
) : (
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
Guest
</Badge>
)}
</div>
</TableCell>
@@ -272,10 +295,17 @@ export function UsersPage() {
</TableCell>
<TableCell>
{profile.couple_id ? (
<Badge variant="secondary">
<Users className="h-3 w-3 mr-1" />
Paired
</Badge>
profile.partner_count && profile.partner_count >= 2 ? (
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
<Users className="h-3 w-3 mr-1" />
Paired
</Badge>
) : (
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
<Hourglass className="h-3 w-3 mr-1" />
Pairing
</Badge>
)
) : (
<span className="text-muted-foreground text-sm">—</span>
)}