Files
silo-server/internal/invitations/service.go
271a2e1741 feat: emailed invitations, claim + household setup, and server-driven onboarding tour (#501)
* feat(invitations): add emailed pre-provisioned invitations

Admins can invite a specific person by email: the invitation pre-binds
role, access group, and library access, and the invitee only chooses a
password. Their email address becomes their username, so login gains an
email fallback (username lookup first, email column only on miss for
inputs that parse as a bare address).

- invitations table: single-use token (SHA-256 at rest) bound to one
  address; a partial unique index makes resend-supersedes atomic; no
  users row exists until accept, so a typo'd address can't squat a
  username. Status is derived from timestamps, not stored.
- internal/invitations: repository, service, and branded email through
  the shared internal/mail sender. When SMTP is off the claim URL is
  returned for manual delivery instead of failing.
- Admin endpoints /admin/invitations (list/create/resend/revoke) beside
  the existing invite-codes routes; public claim endpoints
  /invitations/{token} (+/accept) rate-limited with the other auth
  endpoints. Unknown/expired/revoked/used tokens are indistinguishable.
- Accept returns the same login response shape as signup, so clients
  reuse their session plumbing.

Spec: docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md
Plan: docs/superpowers/plans/2026-07-27-invitations-and-onboarding.md
Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add invitation admin tab, claim page, and household setup

- Admin → Users gains an Invitations tab: compose (email, role, access
  group, libraries, note, first-profile and tour toggles), list with
  derived status, resend, revoke. When the server has no SMTP the create
  response's claim URL is surfaced for copy-paste instead of a fake
  success.
- /invite/:token claim page: everything but the password was decided at
  send time, so it asks for exactly one thing and lands the user signed
  in. Expired/used links get an explanatory card, not a 404.
- /household-setup ("Who's watching?"): profile tiles plus the existing
  ProfileEditorDialog, all through the existing /profiles endpoint —
  no new backend. "Just me for now" is a first-class exit.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): add server-driven onboarding manifest and state

GET /onboarding/flow returns the ordered first-run tour for this server
and profile: steps for disabled features (requests, watch together,
recommendations, notifications) are filtered out server-side, surface=tv
drops steps needing text entry, and child profiles never see stops they
can't act on. Copy lives in Go, so a wording fix is a deploy — clients
render step kinds they know and skip unknown ones by contract.

setting_choice steps name an explicit write target (profile_field /
setting / device_setting) because playback quality is a profile column,
not a settings key — the tour writes through the same APIs the settings
screens use.

Per-profile completion state lives in the user store (SQLite schema v14
+ a Postgres twin table), keyed by (profile_id, tour_id) with monotonic
completed/skipped timestamps: finishing on one device silences every
other; a later progress write can never un-complete.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add the first-run feature tour

TourHost renders the server manifest as a modal overlay on Home: unknown
step kinds are skipped silently (the forward-compat contract), progress
posts per step, and setting_choice steps write real values through the
existing profile/settings mutations — by the last step the account is
genuinely configured. Skip is always one click and recorded server-side,
so no other device re-prompts. The tour ends by handing off to the
existing taste-seed picker, which now waits for the tour to finish
before its own redirect. Settings → Personalize gains a replay entry.

An invitation sent with show_tour=false plants a local hint that the
gate converts into a server-side skip for the first profile.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): satisfy noUncheckedIndexedAccess in the tour's advance step

The Docker web build runs `tsc -b`, which applies the project's
noUncheckedIndexedAccess; the bounds check didn't narrow steps[next].
Look the step up once and branch on its presence instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): blur the whole app behind the tour, sidebar included

The tour overlay rendered inside the app layout, where an ancestor
creates a fixed-position containing block — inset-0 pinned to the
content pane, leaving the sidebar completely un-scrimmed. Portal the
dialog to <body> so the scrim truly covers the viewport, and raise the
backdrop blur from sm (4px) to xl (24px) so card titles and nav labels
aren't legible through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): name features by their UI labels in the tour copy

"Same movie, different couches" never said what the feature is called.
Every feature card now leads with the name the sidebar actually uses —
Watch Party, Requests, Watchlist, Calendar, Notifications — and says
where to find it, so the tour teaches vocabulary, not just concepts.
Server-side copy, so all three clients pick this up with no release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): add apps and Jellyfin-compat steps to the tour

Two new web-only feature cards near the end of the tour:

- "Take Silo with you" — native apps for iPhone/iPad/Apple TV and
  Android/Android TV, with outbound TestFlight and Play Store links.
  Steps gain an additive links field (label + url) that older clients
  ignore; the web TourHost renders them as external-link buttons.
- "Already use a Jellyfin app? It works here" — Infuse/VidHub/Findroid/
  Swiftfin connect via the Jellyfin API. Gated on
  jellyfin_compat.enabled (default-on, so unset counts as enabled;
  only an explicit "false" hides it).

Both steps are web-only: the apps card is pointless inside the apps it
advertises, and TV can't open store links. surface=phone/tv manifests
skip them, covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): keep the tour card responsive on phone widths

Verified every step at 1600px, 390px, and 320px with an automated
overflow check. Fixes it found:

- Link buttons (apps step) now wrap and truncate instead of extending
  past the card edge.
- The footer wraps at very narrow widths, so the handoff step's wide
  primary button drops to its own line rather than overflowing.
- Progress pips hide on phones — decorative, and they crowded the
  Back/Next buttons.
- The card scrolls within 85dvh so a tall step never pins its buttons
  off-screen on landscape phones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): render store links as branded badges in the tour

The apps step's plain outline buttons now render as store badges: the
Apple or Google Play mark with a store eyebrow (TestFlight beta /
Google Play) over the platform label — the familiar app-store badge
idiom. The brand is inferred from the link's host on the client, so
the server contract stays icon-free and non-store links keep the plain
external-link button. Labels drop the parenthesized store name the
eyebrow now carries.

Verified at 1600px and 390px with the overflow sweep: none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:58:18 -04:00

373 lines
11 KiB
Go

package invitations
import (
"context"
"errors"
"fmt"
netmail "net/mail"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/branding"
"github.com/Silo-Server/silo-server/internal/mail"
"github.com/Silo-Server/silo-server/internal/models"
)
// DefaultTTL bounds how long a claim link stays usable.
const DefaultTTL = 7 * 24 * time.Hour
// Account roles an invitation may grant.
const (
roleUser = "user"
roleAdmin = "admin"
)
// Errors surfaced to the API layer.
var (
ErrInvalidEmail = errors.New("invalid email address")
ErrRoleNotAllowed = errors.New("inviter may not grant this role")
ErrEmailTaken = errors.New("an account with this email already exists")
ErrNoLinkBase = errors.New("no external URL is configured for invitation links")
)
// repository is the persistence surface Service needs (satisfied by
// *Repository; an interface so tests can fake it).
type repository interface {
Create(ctx context.Context, input models.CreateInvitationInput, tokenHash string) (*models.Invitation, error)
GetByID(ctx context.Context, id int64) (*models.Invitation, error)
GetByTokenHash(ctx context.Context, tokenHash string) (*models.Invitation, error)
List(ctx context.Context) ([]*models.Invitation, error)
Accept(ctx context.Context, tokenHash string, userID int) error
Revoke(ctx context.Context, id int64) error
Delete(ctx context.Context, id int64) error
}
// userDirectory is the slice of the user repository the service needs.
type userDirectory interface {
GetByEmail(ctx context.Context, email string) (*models.User, error)
GetByUsername(ctx context.Context, username string) (*models.User, error)
GetByID(ctx context.Context, id int) (*models.User, error)
}
// accountCreator provisions the account plus optional default profile.
// Satisfied by *auth.AccountProvisioner.
type accountCreator interface {
CreateAccount(ctx context.Context, input auth.CreateAccountInput) (*models.User, error)
}
// sessionStarter logs the newly created user in. Satisfied by *auth.Service.
type sessionStarter interface {
Login(ctx context.Context, username, password, deviceName, ip string) (*auth.TokenPair, *models.User, error)
}
// settingReader reads server settings (branding name, external URL).
type settingReader interface {
Get(ctx context.Context, key string) (string, error)
}
// Service orchestrates the invitation lifecycle.
type Service struct {
repo repository
users userDirectory
accounts accountCreator
sessions sessionStarter
mail mail.Sender
settings settingReader
publicURL string
ttl time.Duration
now func() time.Time
}
// NewService wires the invitation service. publicURL is the server's
// externally reachable origin, used as the link-base fallback when
// notifications.email.external_url is unset; may be empty.
func NewService(
repo *Repository,
users userDirectory,
accounts accountCreator,
sessions sessionStarter,
mailSender mail.Sender,
settings settingReader,
publicURL string,
) *Service {
return &Service{
repo: repo,
users: users,
accounts: accounts,
sessions: sessions,
mail: mailSender,
settings: settings,
publicURL: strings.TrimRight(publicURL, "/"),
ttl: DefaultTTL,
now: time.Now,
}
}
// SendResult reports what happened to a newly created invitation.
type SendResult struct {
Invitation *models.Invitation
// ClaimURL is returned so the admin can copy the link when email is not
// configured (EmailSent false). It embeds the raw token: the caller must
// only reveal it to the inviting admin.
ClaimURL string
EmailSent bool
}
// SendInput is the admin's request to invite someone.
type SendInput struct {
Email string
Role string
AccessGroupID *int64
LibraryIDs []int
CreateProfile bool
ShowTour bool
Note string
// InvitedBy is the authenticated caller. The inviter's name for the
// email and their admin status for the role-escalation check are read
// from the database, not trusted from the request.
InvitedBy int64
}
// Send validates, supersedes any live invitation for the address, stores the
// new one, and emails the claim link. When email is not configured the
// invitation is still created and the claim URL returned for manual delivery.
func (s *Service) Send(ctx context.Context, input SendInput) (*SendResult, error) {
parsed, err := netmail.ParseAddress(strings.TrimSpace(input.Email))
if err != nil || parsed.Address != strings.TrimSpace(input.Email) {
return nil, ErrInvalidEmail
}
email := parsed.Address
inviter, err := s.users.GetByID(ctx, int(input.InvitedBy))
if err != nil {
return nil, fmt.Errorf("resolving inviter: %w", err)
}
role := input.Role
if role == "" {
role = roleUser
}
if role != roleUser && role != roleAdmin {
return nil, ErrRoleNotAllowed
}
if role == roleAdmin && inviter.Role != roleAdmin {
return nil, ErrRoleNotAllowed
}
// Refuse addresses that already have an account. The address is also the
// future username, so both unique columns are checked.
if _, err := s.users.GetByEmail(ctx, email); err == nil {
return nil, ErrEmailTaken
} else if !auth.IsNotFound(err) {
return nil, fmt.Errorf("checking email: %w", err)
}
if _, err := s.users.GetByUsername(ctx, email); err == nil {
return nil, ErrEmailTaken
} else if !auth.IsNotFound(err) {
return nil, fmt.Errorf("checking username: %w", err)
}
linkBase := s.linkBase(ctx)
if linkBase == "" {
return nil, ErrNoLinkBase
}
token, tokenHash, err := NewToken()
if err != nil {
return nil, err
}
inv, err := s.repo.Create(ctx, models.CreateInvitationInput{
Email: email,
Role: role,
AccessGroupID: input.AccessGroupID,
LibraryIDs: input.LibraryIDs,
CreateProfile: input.CreateProfile,
ShowTour: input.ShowTour,
Note: strings.TrimSpace(input.Note),
InvitedBy: input.InvitedBy,
ExpiresAt: s.now().Add(s.ttl),
}, tokenHash)
if err != nil {
return nil, err
}
claimURL := linkBase + "/invite/" + token
result := &SendResult{Invitation: inv, ClaimURL: claimURL}
content := composeInvitationEmail(
inviter.Username, s.serverName(ctx), email, claimURL, inv.Note, inv.ExpiresAt, s.now())
err = s.mail.Send(ctx, mail.Message{
To: []string{email},
Subject: content.Subject,
TextBody: content.Text,
HTMLBody: content.HTML,
})
switch {
case err == nil:
result.EmailSent = true
case errors.Is(err, mail.ErrNotConfigured):
// Degrade gracefully: the admin copies the link instead.
default:
return nil, fmt.Errorf("send invitation email: %w", err)
}
return result, nil
}
// Resend supersedes an invitation with a fresh token to the same address,
// re-using the original access choices. The old link stops working. The
// resending admin becomes the inviter of record.
func (s *Service) Resend(ctx context.Context, id, resentBy int64) (*SendResult, error) {
prior, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return s.Send(ctx, SendInput{
Email: prior.Email,
Role: prior.Role,
AccessGroupID: prior.AccessGroupID,
LibraryIDs: prior.LibraryIDs,
CreateProfile: prior.CreateProfile,
ShowTour: prior.ShowTour,
Note: prior.Note,
InvitedBy: resentBy,
})
}
// List returns all invitations, newest first.
func (s *Service) List(ctx context.Context) ([]*models.Invitation, error) {
return s.repo.List(ctx)
}
// Revoke kills a live invitation link.
func (s *Service) Revoke(ctx context.Context, id int64) error {
return s.repo.Revoke(ctx, id)
}
// LookupResult is the claim screen's view of an invitation: only what it
// renders, nothing else leaves the server pre-auth.
type LookupResult struct {
Email string
InviterName string
ServerName string
ExpiresAt time.Time
ShowTour bool
}
// Lookup resolves a raw claim token for the claim screen. Unknown, expired,
// revoked, and accepted tokens all return ErrNotFound: a probe learns
// nothing about which.
func (s *Service) Lookup(ctx context.Context, token string) (*LookupResult, error) {
inv, err := s.claimable(ctx, token)
if err != nil {
return nil, err
}
return &LookupResult{
Email: inv.Email,
InviterName: inv.InvitedByName,
ServerName: s.serverName(ctx),
ExpiresAt: inv.ExpiresAt,
ShowTour: inv.ShowTour,
}, nil
}
// Accept redeems the invitation: creates the account with the pre-bound
// access (username = email), claims the row, and logs the user in. Of two
// concurrent accepts exactly one wins; the loser's account creation is
// prevented by the users table's unique constraints, and the row claim by
// Accept's WHERE predicate.
func (s *Service) Accept(ctx context.Context, token, password, deviceName, ip string) (*auth.TokenPair, *models.User, error) {
inv, err := s.claimable(ctx, token)
if err != nil {
return nil, nil, err
}
user, err := s.accounts.CreateAccount(ctx, auth.CreateAccountInput{
User: models.CreateUserInput{
Username: inv.Email,
Email: inv.Email,
Password: password,
Role: inv.Role,
LibraryIDs: inv.LibraryIDs,
AccessGroupID: inv.AccessGroupID,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: inv.CreateProfile,
Name: profileNameFromEmail(inv.Email),
},
})
if err != nil {
if auth.IsDuplicate(err) {
// Lost a race with a concurrent accept, or the address gained an
// account since the invitation was sent.
return nil, nil, ErrNotClaimable
}
return nil, nil, fmt.Errorf("creating invited user: %w", err)
}
if err := s.repo.Accept(ctx, HashToken(token), user.ID); err != nil {
// The row was consumed between claimable() and here. The account
// exists; surface the claim failure rather than leaving a half-open
// success. Admins can delete the orphan from the users screen.
return nil, nil, err
}
return s.sessions.Login(ctx, inv.Email, password, deviceName, ip)
}
// claimable fetches a pending, unexpired, unrevoked invitation by raw token.
func (s *Service) claimable(ctx context.Context, token string) (*models.Invitation, error) {
if strings.TrimSpace(token) == "" {
return nil, ErrNotFound
}
inv, err := s.repo.GetByTokenHash(ctx, HashToken(token))
if err != nil {
return nil, err
}
if inv.Status(s.now()) != models.InvitationStatusPending {
return nil, ErrNotFound
}
return inv, nil
}
// linkBase resolves the externally reachable base URL for claim links:
// notifications.email.external_url, falling back to the server public URL.
func (s *Service) linkBase(ctx context.Context) string {
if s.settings != nil {
if base, err := s.settings.Get(ctx, "notifications.email.external_url"); err == nil {
if base = strings.TrimRight(strings.TrimSpace(base), "/"); base != "" {
return base
}
}
}
return s.publicURL
}
// serverName reads the branded server name for email copy and the claim
// screen, defaulting to "Silo".
func (s *Service) serverName(ctx context.Context) string {
if s.settings != nil {
if name, err := s.settings.Get(ctx, branding.KeyServerName); err == nil {
if name = strings.TrimSpace(name); name != "" {
return name
}
}
}
return branding.DefaultServerName
}
// profileNameFromEmail derives the default profile name from the address's
// local part ("marco@example.com" → "Marco").
func profileNameFromEmail(email string) string {
local := email
if at := strings.IndexByte(email, '@'); at > 0 {
local = email[:at]
}
local = strings.TrimSpace(local)
if local == "" {
return ""
}
return strings.ToUpper(local[:1]) + local[1:]
}