Add per-family invite codes and admin roles

First member into a family becomes Admin; subsequent members default to
Member. JWTs carry a family_role claim that is refreshed from the DB on
each request so promotions and demotions take effect immediately.

New /api/family endpoints let admins view the roster, regenerate the
invite code, change roles, and remove members. Last-admin and
self-removal guards prevent locking the family out of management.

The /family page exposes the same actions in the UI; the bottom nav now
links there. Members see the roster but not the invite code.

Existing deployments get a one-time backfill at startup: any family with
members but no admin gets its earliest-joined member promoted.
This commit is contained in:
Josh Rogers
2026-05-08 21:29:15 -05:00
parent d4db819e72
commit de5c18f3e6
9 changed files with 660 additions and 18 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
const navItems = [
{ href: '/lists', label: 'Lists', icon: '📋' },
{ href: '/recipes', label: 'Recipes', icon: '📖' },
{ href: '/stores', label: 'Stores', icon: '🏪' }
{ href: '/stores', label: 'Stores', icon: '🏪' },
{ href: '/family', label: 'Family', icon: '👪' }
];
function logout() {
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
type Role = 'Admin' | 'Member';
interface Member {
userId: number;
name: string;
role: Role;
joinedAt: string;
}
interface FamilyView {
id: number;
name: string;
inviteCode: string | null;
myRole: Role;
members: Member[];
}
interface Me {
id: number;
name: string;
familyId: number;
familyName: string;
role: Role;
}
let family = $state<FamilyView | null>(null);
let me = $state<Me | null>(null);
let loading = $state(true);
let pendingRemove = $state<Member | null>(null);
let pendingRegenerate = $state(false);
const isAdmin = $derived(family?.myRole === 'Admin');
onMount(async () => {
await refresh();
loading = false;
});
async function refresh() {
[family, me] = await Promise.all([
api<FamilyView>('/api/family'),
api<Me>('/api/auth/me')
]);
}
async function copyInviteCode() {
if (!family?.inviteCode) return;
try {
await navigator.clipboard.writeText(family.inviteCode);
toast.success('Invite code copied');
} catch {
toast.error('Could not copy — long-press to select instead');
}
}
async function regenerateInviteCode() {
pendingRegenerate = false;
try {
const res = await api<{ inviteCode: string }>(
'/api/family/invite-code/regenerate',
{ method: 'POST' }
);
if (family) family.inviteCode = res.inviteCode;
toast.success('New invite code generated');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to regenerate code');
}
}
async function setRole(member: Member, role: Role) {
try {
await api(`/api/family/members/${member.userId}/role`, {
method: 'PUT',
body: JSON.stringify({ role })
});
await refresh();
toast.success(role === 'Admin' ? `${member.name} promoted to admin` : `${member.name} is now a member`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to change role');
}
}
async function confirmRemove() {
if (!pendingRemove) return;
const target = pendingRemove;
pendingRemove = null;
try {
await api(`/api/family/members/${target.userId}`, { method: 'DELETE' });
await refresh();
toast.success(`${target.name} removed from the family`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to remove member');
}
}
</script>
<div>
<h2 class="mb-1 text-2xl font-bold">{family?.name ?? 'Family'}</h2>
<p class="mb-6 text-sm text-gray-500">
{#if family}
{family.members.length} member{family.members.length === 1 ? '' : 's'}
{/if}
</p>
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else if !family}
<p class="py-8 text-center text-gray-400">Family not found.</p>
{:else}
{#if isAdmin && family.inviteCode}
<section class="mb-6 rounded-lg bg-white p-4 shadow-sm">
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Invite code
</h3>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-lg tracking-widest">
{family.inviteCode}
</code>
<button
type="button"
onclick={copyInviteCode}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
>
Copy
</button>
</div>
<button
type="button"
onclick={() => (pendingRegenerate = true)}
class="mt-3 text-sm font-medium text-danger"
>
Regenerate code
</button>
<p class="mt-2 text-xs text-gray-500">
Share this code with people you want to join. Regenerating immediately invalidates the old code.
</p>
</section>
{/if}
<section>
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">Members</h3>
<ul class="space-y-2">
{#each family.members as member (member.userId)}
{@const isMe = member.userId === me?.id}
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{member.name}</span>
{#if isMe}
<span class="text-xs text-gray-400">(you)</span>
{/if}
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold {member.role === 'Admin'
? 'bg-primary/10 text-primary'
: 'bg-gray-100 text-gray-600'}"
>
{member.role}
</span>
</div>
</div>
{#if isAdmin}
<div class="flex items-center gap-2 text-sm">
{#if member.role === 'Member'}
<button
type="button"
onclick={() => setRole(member, 'Admin')}
class="font-medium text-primary"
>
Promote
</button>
{:else}
<button
type="button"
onclick={() => setRole(member, 'Member')}
class="font-medium text-gray-600"
>
Demote
</button>
{/if}
{#if !isMe}
<button
type="button"
onclick={() => (pendingRemove = member)}
class="font-medium text-danger"
>
Remove
</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
</div>
{#if pendingRemove}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="remove-member-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="remove-member-title" class="mb-2 text-lg font-semibold">Remove member?</h3>
<p class="mb-5 text-sm text-gray-600">
Remove <span class="font-medium">{pendingRemove.name}</span> from the family? They'll lose access immediately.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingRemove = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={confirmRemove}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Remove
</button>
</div>
</div>
</div>
{/if}
{#if pendingRegenerate}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="regenerate-code-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="regenerate-code-title" class="mb-2 text-lg font-semibold">Regenerate invite code?</h3>
<p class="mb-5 text-sm text-gray-600">
The current code will stop working immediately. Anyone you've already shared it with will need the new one.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingRegenerate = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={regenerateInviteCode}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Regenerate
</button>
</div>
</div>
</div>
{/if}