Overview
Allow admins to invite new users to a Voy instance via email. An admin creates an invitation from the Settings panel, a unique link is generated and displayed, and the invitee uses that link to set a password and gain access.
User stories
- As an admin, I can navigate to a new Users section in Settings and see a list of existing users and pending invitations.
- As an admin, I can invite a new user by entering their email address (and optionally assigning them a role:
user or admin).
- As an admin, I can copy the generated invitation link to share it manually.
- As an admin, I can revoke a pending invitation.
- As an invited user, I can open the invitation link, set a display name and password, and immediately access the search engine.
- As an invited user, the link expires after 7 days and becomes invalid once used.
Technical scope
Backend
- Add an
invitation table to the Drizzle schema:
id, email, role (user | admin), token (unique, random), invitedBy (FK → user.id), expiresAt, createdAt, usedAt (nullable — null = still pending)
- Add server functions (admin-only, following the pattern of
api-keys.ts):
createInvitation(email, role) — checks no account already exists for that email, generates a secure token, inserts a row
listInvitations() — returns pending (unused, non-expired) invitations
revokeInvitation(id) — deletes the row
- Add public server functions (no auth required):
getInvitation(token) — returns the invitation if valid (not used, not expired), 404 otherwise
acceptInvitation(token, name, password) — validates the token, calls auth.api.createUser() with the stored email and role, marks usedAt, auto-signs in the new user
Frontend — Settings > Users (admin-only)
- New route:
src/routes/_authed/settings/users.tsx
- New
beforeLoad guard (same pattern as settings/api.tsx)
- New sidebar entry in
adminNavItems in settings-sidebar.tsx
- UI:
- Table of existing users (name, email, role, joined date) — via
auth.api.listUsers() from the Better Auth admin plugin
- "Invite user" button → dialog with email + role fields
- On submit: call
createInvitation, display the generated link in a copyable input
- Table of pending invitations (email, role, invited by, expires at) with a "Revoke" action
Frontend — Accept invitation page (public)
- New route:
src/routes/invite/$token.tsx (outside _authed, same pattern as /setup and /login)
- Loads the invitation via
getInvitation(token) in beforeLoad — redirects to / or shows an error if invalid/expired
- Form: display name + password + confirm password
- On submit: call
acceptInvitation, redirect to / on success
i18n
- Add keys for all new UI strings in both
en and fr locale files.
Out of scope
- Sending invitation emails automatically (no SMTP config exists — the link is displayed for the admin to share manually)
- Bulk invitations
- Invitation resend
Acceptance criteria
Overview
Allow admins to invite new users to a Voy instance via email. An admin creates an invitation from the Settings panel, a unique link is generated and displayed, and the invitee uses that link to set a password and gain access.
User stories
useroradmin).Technical scope
Backend
invitationtable to the Drizzle schema:id,email,role(user|admin),token(unique, random),invitedBy(FK →user.id),expiresAt,createdAt,usedAt(nullable — null = still pending)api-keys.ts):createInvitation(email, role)— checks no account already exists for that email, generates a secure token, inserts a rowlistInvitations()— returns pending (unused, non-expired) invitationsrevokeInvitation(id)— deletes the rowgetInvitation(token)— returns the invitation if valid (not used, not expired), 404 otherwiseacceptInvitation(token, name, password)— validates the token, callsauth.api.createUser()with the stored email and role, marksusedAt, auto-signs in the new userFrontend — Settings > Users (admin-only)
src/routes/_authed/settings/users.tsxbeforeLoadguard (same pattern assettings/api.tsx)adminNavItemsinsettings-sidebar.tsxauth.api.listUsers()from the Better Auth admin plugincreateInvitation, display the generated link in a copyable inputFrontend — Accept invitation page (public)
src/routes/invite/$token.tsx(outside_authed, same pattern as/setupand/login)getInvitation(token)inbeforeLoad— redirects to/or shows an error if invalid/expiredacceptInvitation, redirect to/on successi18n
enandfrlocale files.Out of scope
Acceptance criteria
invitationtable