diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a976387..f0eceeb 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -162,6 +162,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) + // Purge the account-deletion legal dossier past its retention TTL: the + // retained-identities journal, and the feedback thread + dossier PII of long-deleted + // accounts (chat is kept). Checked daily; the TTL is a two-year policy constant. + retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger) + go retentionReaper.Run(ctx, 24*time.Hour) + logger.Info("retention reaper started", + zap.Duration("interval", 24*time.Hour), + zap.Duration("retention", account.RetentionTTL)) + // Re-evaluate moderated-chat write access when a temporary block self-expires: // no operator action fires then, so the sweeper emits the chat-access-changed // event for lapsed blocks and the gateway re-pushes the chat-gate command. diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 3b65cbb..102bd73 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -42,6 +42,7 @@ const ( purposeLogin = "login" purposeLink = "link" purposeChange = "change" + purposeDelete = "delete" ) // Errors returned by the email confirm-code flow. @@ -65,6 +66,9 @@ var ( // ErrTooManyRequests is returned when confirm-code sends to an address are being // requested too frequently (the resend cooldown or the rolling-hour cap). ErrTooManyRequests = errors.New("account: too many code requests") + // ErrNoEmail is returned when an email-code step-up is requested for an account that + // holds no confirmed email (the caller must use the typed-phrase path instead). + ErrNoEmail = errors.New("account: no confirmed email") ) // EmailService runs the email confirm-code flow: it issues a 6-digit code over a @@ -115,7 +119,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { return err } - msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale) + // Account deletion is never a one-tap link (a prefetch or a stray click must not delete + // an account): the delete code is entered in the app only, so the email omits the + // deeplink and ConfirmByToken refuses a delete token. + deeplink := s.confirmURL(token, locale) + if purpose == purposeDelete { + deeplink = "" + } + msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) if err != nil { return err } @@ -195,6 +206,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { return Account{}, err } + // Binding the first confirmed email promotes a guest to a durable account, matching the + // link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set). + if err := s.store.ClearGuest(ctx, accountID); err != nil { + return Account{}, err + } return s.store.GetByID(ctx, accountID) } @@ -347,6 +363,9 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo return LinkConfirmation{}, err } return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil + case purposeDelete: + // Deletion is confirmed in the app with the code, never via a one-tap link. + return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link") default: return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose) } @@ -380,6 +399,27 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U return row.AccountID, true, nil } +// confirmedEmailOf returns the account's confirmed email address and true, or ("", false) +// when it holds none. It backs the deletion step-up, which mails a code to the account's +// own address. +func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) { + stmt := postgres.SELECT(table.Identities.ExternalID). + FROM(table.Identities). + WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))). + AND(table.Identities.Confirmed.EQ(postgres.Bool(true))), + ).LIMIT(1) + var row model.Identities + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err) + } + return row.ExternalID, true, nil +} + // replacePendingConfirmation clears any pending code for (accountID, email) and // inserts a fresh one, inside one transaction. func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error { @@ -546,6 +586,25 @@ func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accoun if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("consume confirmation: %w", err) } + // Journal the outgoing email before replacing it, so the legal dossier keeps the + // address the account used to hold (see retention.go). + var old model.Identities + sel := postgres.SELECT( + table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt, + ).FROM(table.Identities).WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ).LIMIT(1) + switch err := sel.QueryContext(ctx, tx, &old); { + case err == nil: + if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil { + return err + } + case errors.Is(err, qrm.ErrNoRows): + // No prior email (this doubles as an attach); nothing to retain. + default: + return fmt.Errorf("load outgoing email identity: %w", err) + } del := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go index 9222c2c..7bff757 100644 --- a/backend/internal/account/emailtemplate.go +++ b/backend/internal/account/emailtemplate.go @@ -98,6 +98,24 @@ var confirmEmailCopy = map[string]map[string]emailCopy{ FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.", }, }, + purposeDelete: { + "en": { + Subject: "Confirm your Erudit account deletion", + Preheader: "Confirm account deletion", + Heading: "Delete your account", + Intro: "Enter this code in the app to permanently delete your account:", + CTALabel: "", + FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.", + }, + "ru": { + Subject: "Подтвердите удаление аккаунта Эрудит", + Preheader: "Подтверждение удаления аккаунта", + Heading: "Удаление аккаунта", + Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:", + CTALabel: "", + FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.", + }, + }, } // emailBrand is the brand wordmark per locale. diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index fe21dc7..c897e15 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -31,21 +31,29 @@ func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind st if err != nil { return err } - hasKind, others := false, 0 + var toRetain []Identity + others := 0 for _, id := range ids { if id.Kind == kind { - hasKind = true + toRetain = append(toRetain, id) } else { others++ } } - if !hasKind { + if len(toRetain) == 0 { return ErrNotFound } if others == 0 { return ErrLastIdentity } return withTx(ctx, s.db, func(tx *sql.Tx) error { + // Journal the detached credential before removing it, so the legal dossier + // survives while the identity frees for reuse (see retention.go). + for _, id := range toRetain { + if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil { + return err + } + } delID := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(kind))), @@ -170,6 +178,49 @@ func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, n return s.store.GetByID(ctx, accountID) } +// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up +// mails a confirm-code when it does, and falls back to a typed phrase otherwise. +func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) { + _, ok, err := s.store.confirmedEmailOf(ctx, accountID) + return ok, err +} + +// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed +// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the +// account holds no email, ErrTooManyRequests when throttled. +func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error { + addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) + if err != nil { + return err + } + if !ok { + return ErrNoEmail + } + if !s.allowSend(addr) { + return ErrTooManyRequests + } + return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID)) +} + +// VerifyDeleteCode verifies the account-deletion code against the account's own email and +// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors +// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the +// code is valid — the caller then performs the deletion. +func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error { + addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) + if err != nil { + return err + } + if !ok { + return ErrNoEmail + } + conf, err := s.verifyPendingCode(ctx, accountID, addr, code) + if err != nil { + return err + } + return s.store.consumeConfirmation(ctx, conf.id, s.now()) +} + // verifyPendingCode loads and checks the pending confirm-code for (accountID, // addr), counting a wrong attempt. It returns the confirmation on success. func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) { diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go new file mode 100644 index 0000000..14c6bab --- /dev/null +++ b/backend/internal/account/retention.go @@ -0,0 +1,222 @@ +package account + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// RetentionTTL bounds how long the account-deletion legal dossier is kept before the +// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03). +const RetentionTTL = 2 * 365 * 24 * time.Hour + +// Reasons recorded on a retained_identities row: what detached the credential from its +// account. The row is written just before the live identities row is removed, preserving +// the legal dossier (which email/vk/tg was linked, and when) even as the identity frees +// for reuse. See docs/ARCHITECTURE.md §9.1. +const ( + retainUnlink = "unlink" + retainChange = "change" + retainDelete = "delete" +) + +// retainIdentityTx appends a retention-journal row for one identity being detached, inside +// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It +// must run in the same transaction as the identity removal, so the dossier and the live +// state can never diverge. +func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new retained id: %w", err) + } + ins := table.RetainedIdentities.INSERT( + table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, + table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, + table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, + table.RetainedIdentities.Reason, + ).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err) + } + return nil +} + +// StampLastLogin records the account's last cold-load time and client IP, but only when +// the stored value is missing or older than an hour — so it costs at most one write per +// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a +// best-effort audit signal that feeds the account-deletion dossier. +func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error { + now := time.Now().UTC() + upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP). + SET(postgres.TimestampzT(now), postgres.String(ip)). + WHERE( + table.Accounts.AccountID.EQ(postgres.UUID(accountID)). + AND( + table.Accounts.LastLoginAt.IS_NULL(). + OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))), + ), + ) + if _, err := upd.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: stamp last login %s: %w", accountID, err) + } + return nil +} + +// ReapExpiredRetention purges retention data whose event is older than cutoff: every +// retained_identities row by its detached_at (covering unlink/change on live accounts as +// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained +// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept +// (a shared game artifact), and the tombstone account row itself stays (its no-cascade +// foreign keys). It returns how many journal rows and feedback messages were removed. +func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) { + cut := postgres.TimestampzT(cutoff) + delJournal := table.RetainedIdentities.DELETE(). + WHERE(table.RetainedIdentities.DetachedAt.LT(cut)) + res, err := delJournal.ExecContext(ctx, s.db) + if err != nil { + return 0, 0, fmt.Errorf("account: reap retained identities: %w", err) + } + identities, _ = res.RowsAffected() + + expired := postgres.SELECT(table.Accounts.AccountID). + FROM(table.Accounts). + WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut))) + delFeedback := table.FeedbackMessages.DELETE(). + WHERE(table.FeedbackMessages.AccountID.IN(expired)) + fbRes, err := delFeedback.ExecContext(ctx, s.db) + if err != nil { + return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err) + } + feedback, _ = fbRes.RowsAffected() + + clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP). + SET(postgres.NULL, postgres.NULL). + WHERE( + table.Accounts.DeletedAt.IS_NOT_NULL(). + AND(table.Accounts.DeletedAt.LT(cut)). + AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL(). + OR(table.Accounts.LastLoginIP.IS_NOT_NULL())), + ) + if _, err := clearPII.ExecContext(ctx, s.db); err != nil { + return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err) + } + return identities, feedback, nil +} + +// RetainedIdentity is one row of the retention journal, for the admin dossier. +type RetainedIdentity struct { + Kind string + ExternalID string + Reason string + Confirmed bool + LinkedAt time.Time + DetachedAt time.Time +} + +// RetainedIdentities returns the account's retention-journal rows (the legal dossier of +// detached credentials), newest detach first, for the admin console. +func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) { + var rows []model.RetainedIdentities + err := postgres.SELECT(table.RetainedIdentities.AllColumns). + FROM(table.RetainedIdentities). + WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))). + ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()). + QueryContext(ctx, s.db, &rows) + if err != nil && !errors.Is(err, qrm.ErrNoRows) { + return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err) + } + out := make([]RetainedIdentity, 0, len(rows)) + for _, r := range rows { + out = append(out, RetainedIdentity{ + Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason, + Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt, + }) + } + return out, nil +} + +// DeletionInfo is a tombstoned account's dossier header, for the admin console. +type DeletionInfo struct { + DeletedAt *time.Time + DeletedDisplayName string + LastLoginAt *time.Time + LastLoginIP string +} + +// DeletionInfo reads the account's deletion tombstone + last-login dossier fields. +func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) { + var row model.Accounts + err := postgres.SELECT( + table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, + table.Accounts.LastLoginAt, table.Accounts.LastLoginIP, + ).FROM(table.Accounts). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))). + QueryContext(ctx, s.db, &row) + if err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return DeletionInfo{}, ErrNotFound + } + return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err) + } + info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt} + if row.DeletedDisplayName != nil { + info.DeletedDisplayName = *row.DeletedDisplayName + } + if row.LastLoginIP != nil { + info.LastLoginIP = *row.LastLoginIP + } + return info, nil +} + +// RetentionReaper periodically purges expired account-deletion retention data via +// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once +// from main. +type RetentionReaper struct { + store *Store + ttl time.Duration + clock func() time.Time + log *zap.Logger +} + +// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be +// nil. +func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper { + if log == nil { + log = zap.NewNop() + } + return &RetentionReaper{ + store: store, + ttl: ttl, + clock: func() time.Time { return time.Now().UTC() }, + log: log, + } +} + +// Run purges expired retention data on each tick until ctx is cancelled. +func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl)) + if err != nil { + r.log.Warn("retention reap failed", zap.Error(err)) + } else if idn > 0 || fb > 0 { + r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb)) + } + } + } +} diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 9103c9f..4f3b6fc 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -19,6 +19,9 @@ type UserListItem struct { PreferredLanguage string IsGuest bool IsRobot bool + // IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search + // spans both lists, so a result can be either live or deleted. + IsDeleted bool // FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown // as a badge in the console list. FlaggedHighRateAt time.Time @@ -26,12 +29,14 @@ type UserListItem struct { } // UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the -// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = -// one char) matched case-insensitively against the display name / any identity's external -// id; EmailExact is a strict (exact) match against an account's email identity. An empty -// value means no filter on that field. +// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them); +// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched +// case-insensitively against the display name / any identity's external id; EmailExact is a +// strict (exact) match against an account's email identity. An empty value means no filter +// on that field. type UserFilter struct { Robots bool + Deleted bool NameMask string ExternalIDMask string EmailExact string @@ -53,21 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) return ok, nil } -// userListWhere builds the shared WHERE clause and its positional args (from $1). +// userListWhere builds the shared WHERE clause and its positional args (from $1). On the +// Robots tab it lists/searches robots only. Otherwise a search (any of the name / +// external-id / email filters) spans live and deleted people alike — never robots — so the +// operator finds a match from one query regardless of the People / Deleted tab; the search +// also looks in the retention journal, so a deleted account is still found by the email / +// external id it held (those rows moved from identities to retained_identities on deletion) +// and by its retained real name. With no search, the People / Deleted tab scope applies. func userListWhere(f UserFilter) (string, []any) { - args := []any{f.Robots} - where := robotExists + ` = $1` - if name := LikePattern(f.NameMask); name != "" { + name := LikePattern(f.NameMask) + ext := LikePattern(f.ExternalIDMask) + email := strings.ToLower(strings.TrimSpace(f.EmailExact)) + searching := name != "" || ext != "" || email != "" + + var args []any + var where string + switch { + case f.Robots: + where = robotExists + ` = true` + case searching: + where = robotExists + ` = false` + case f.Deleted: + where = robotExists + ` = false AND a.deleted_at IS NOT NULL` + default: + where = robotExists + ` = false AND a.deleted_at IS NULL` + } + if name != "" { args = append(args, name) - where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args)) } - if ext := LikePattern(f.ExternalIDMask); ext != "" { + if ext != "" { args = append(args, ext) - where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) + where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args)) } - if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" { + if email != "" { args = append(args, email) - where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args)) + where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args)) } return where, args } @@ -75,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) { // ListUsers returns the filtered admin user list, newest first, paginated. func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) { where, args := userListWhere(f) - q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot + q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted FROM backend.accounts a WHERE ` + where + fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) args = append(args, limit, offset) @@ -88,7 +114,7 @@ FROM backend.accounts a WHERE ` + where + for rows.Next() { var it UserListItem var flagged sql.NullTime - if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil { + if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil { return nil, fmt.Errorf("account: scan user: %w", err) } if flagged.Valid { diff --git a/backend/internal/accountdelete/delete.go b/backend/internal/accountdelete/delete.go new file mode 100644 index 0000000..b36c28e --- /dev/null +++ b/backend/internal/accountdelete/delete.go @@ -0,0 +1,223 @@ +// Package accountdelete deactivates an account as legal retention, not erasure: it keeps +// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a +// hard delete is impossible) while journalling and freeing the account's credentials, +// anonymising the live surfaces, and dropping the account's own social/ephemeral rows. +// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name, +// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session +// revocation and active-game forfeit are orchestrated one layer up (they need the session +// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper. +package accountdelete + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// AnonymizedName is the label a deleted account shows to opponents. Display names are +// stored strings resolved identically for every viewer (no per-viewer localisation in this +// codebase), so a single canonical label is used. The brackets are deliberate: the +// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a +// name that impersonates a deleted account. +const AnonymizedName = "[Deleted]" + +// retainDelete is the retained_identities reason written when a credential is journalled +// because its account is being deleted. +const retainDelete = "delete" + +// Deleter performs the SQL-atomic part of account deletion over a Postgres handle. +type Deleter struct { + db *sql.DB + now func() time.Time +} + +// NewDeleter constructs a Deleter over db. +func NewDeleter(db *sql.DB) *Deleter { + return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }} +} + +// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into +// retained_identities (reason=delete) then removes them so the credentials free for reuse, +// snapshots the real display name into deleted_display_name and scrubs the live one to +// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops +// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat, +// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign +// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling +// nothing, since the identities are already gone). +func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error { + now := d.now() + return withTx(ctx, d.db, func(tx *sql.Tx) error { + if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil { + return err + } + if err := tombstone(ctx, tx, accountID, now); err != nil { + return err + } + if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName). + SET(postgres.String(AnonymizedName)). + WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: anonymise seats: %w", err) + } + return dropSocialAndEphemerals(ctx, tx, accountID) + }) +} + +// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a +// human — a robot seat is one whose account holds a 'robot' identity, so this covers both +// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their +// moves/chat/players/complaints fall away through ON DELETE CASCADE. +const dropAllRobotGamesSQL = ` +DELETE FROM games g +WHERE EXISTS ( + SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1 +) AND NOT EXISTS ( + SELECT 1 FROM game_players o + WHERE o.game_id = g.game_id AND o.account_id <> $1 + AND NOT EXISTS ( + SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot' + ) +)` + +// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or +// auto-match-robot games), returning how many were removed. Games with any human seat are +// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the +// account's active games are resigned, so no live game is removed under the robot driver. +func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) { + res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID) + if err != nil { + return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("accountdelete: dropped games count: %w", err) + } + return n, nil +} + +// journalAndDropIdentities copies the account's live identities into the retention journal +// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. +func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + var ids []model.Identities + err := postgres.SELECT(table.Identities.AllColumns). + FROM(table.Identities). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + QueryContext(ctx, tx, &ids) + if err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountdelete: load identities: %w", err) + } + for _, id := range ids { + rid, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("accountdelete: new retained id: %w", err) + } + ins := table.RetainedIdentities.INSERT( + table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, + table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, + table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, + table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason, + ).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err) + } + } + if _, err := table.Identities.DELETE(). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete identities: %w", err) + } + return nil +} + +// tombstone marks the account deleted, snapshotting the real display name into +// deleted_display_name (evaluated from the old row) before scrubbing the live one. +func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + upd := table.Accounts.UPDATE( + table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, + table.Accounts.DisplayName, table.Accounts.UpdatedAt, + ).SET( + postgres.TimestampzT(now), table.Accounts.DisplayName, + postgres.String(AnonymizedName), postgres.TimestampzT(now), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: tombstone account: %w", err) + } + return nil +} + +// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations +// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are +// the deleting user's private data with no dossier value; chat and feedback are kept. +func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error { + id := postgres.UUID(accountID) + // Friendships and blocks are two-account edges keyed on either endpoint. + if _, err := table.Friendships.DELETE(). + WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friendships: %w", err) + } + if _, err := table.Blocks.DELETE(). + WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete blocks: %w", err) + } + // Invitations: drop the account's invitee rows, then its own invitations' invitees and + // the invitations themselves (children first, to respect the foreign key). + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.AccountID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitee rows: %w", err) + } + ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID). + FROM(table.GameInvitations). + WHERE(table.GameInvitations.InviterID.EQ(id)) + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err) + } + if _, err := table.GameInvitations.DELETE(). + WHERE(table.GameInvitations.InviterID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitations: %w", err) + } + // Ephemerals: friend codes, move drafts, pending confirm-codes. + if _, err := table.FriendCodes.DELETE(). + WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friend codes: %w", err) + } + if _, err := table.GameDrafts.DELETE(). + WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete drafts: %w", err) + } + if _, err := table.EmailConfirmations.DELETE(). + WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete confirmations: %w", err) + } + return nil +} + +// withTx runs fn inside a transaction, committing on success and rolling back on error. +func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("accountdelete: begin tx: %w", err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("accountdelete: commit tx: %w", err) + } + return nil +} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 58194f6..2d300d1 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -107,6 +107,24 @@ {{end}} +

Deletion & retention

+{{if .LastLoginAt}}

Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — {{.LastLoginIP}}{{end}}

{{end}} +{{if .Deleted}}

Deleted at {{.DeletedAt}}{{if .DeletedName}} — was {{.DeletedName}}{{end}}

{{end}} +{{if .Retained}} +

Retention journal (legal dossier of detached credentials)

+ + + +{{range .Retained}}{{end}} + +
KindCredentialReasonDetached
{{.Kind}}{{.ExternalID}}{{.Reason}}{{.DetachedAt}}
+{{end}} +{{if not .Deleted}} +
+ +
+{{end}} +

Friends

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 9503578..f6499ea 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -2,11 +2,12 @@

Users

{{with .Data}} -{{if .Robots}}{{end}} +{{if .Robots}}{{end}}{{if .Deleted}}{{end}} @@ -18,7 +19,7 @@ {{range .Items}} - + diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index ffded10..deddfe2 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -60,6 +60,7 @@ type UsersView struct { // be emitted verbatim — interpolated as a plain string it would have its "=" and "&" // percent-encoded again by the contextual escaper. Robots bool + Deleted bool NameMask string ExternalIDMask string EmailExact string @@ -75,6 +76,7 @@ type UserRow struct { Kind string Language string Guest bool + Deleted bool FlaggedHighRate bool CreatedAt string HasMoveStats bool @@ -151,6 +153,15 @@ type UserDetailView struct { // MergedInto is the primary account id when this account has been retired by a // merge, or empty for a live account. MergedInto string + // The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and + // DeletedName are its deletion time and retained real name; LastLoginAt/IP are the + // last cold-load stamp (shown for any account); Retained is the credential journal. + Deleted bool + DeletedAt string + DeletedName string + LastLoginAt string + LastLoginIP string + Retained []RetainedRow // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // empty for an unflagged account; the card shows it with the Clear action. FlaggedHighRateAt string @@ -237,6 +248,17 @@ type IdentityRow struct { CreatedAt string } +// RetainedRow is one credential in the account-deletion retention journal (the legal +// dossier of detached credentials): what was detached, when, and why. +type RetainedRow struct { + Kind string + ExternalID string + Reason string + Confirmed bool + LinkedAt string + DetachedAt string +} + // GameRow is one game row in a list. type GameRow struct { ID string diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go new file mode 100644 index 0000000..802e0e4 --- /dev/null +++ b/backend/internal/inttest/delete_test.go @@ -0,0 +1,322 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// deletedFields reads a tombstoned account's retained real name and its deleted_at. +func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) { + t.Helper() + var dn sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID). + Scan(&dn, &deletedAt) + if err != nil { + t.Fatalf("read deleted fields %s: %v", accountID, err) + } + return dn.String, deletedAt +} + +// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the +// account, scrubs the live name while retaining the real one, and frees the creds for a +// new account to reuse. +func TestAnonymizeAndTombstone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00") + if err != nil { + t.Fatalf("provision: %v", err) + } + email := "del-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("attach email: %v", err) + } + before, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load before: %v", err) + } + + if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // The live identities are gone. + if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 { + t.Fatalf("identities after delete = %+v (err %v), want none", ids, err) + } + // Both credentials are journalled with reason=delete. + got := retainedRows(t, acc.ID) + if len(got) != 2 { + t.Fatalf("retained rows = %+v, want 2", got) + } + for _, r := range got { + if r.reason != "delete" { + t.Errorf("retained reason = %q, want delete", r.reason) + } + } + // The live name is scrubbed; the real one is retained; deleted_at is set. + after, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load after: %v", err) + } + if after.DisplayName != accountdelete.AnonymizedName { + t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName) + } + name, deletedAt := deletedFields(t, acc.ID) + if name != before.DisplayName { + t.Errorf("retained name = %q, want %q", name, before.DisplayName) + } + if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute { + t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt) + } + // The credentials are free: a new account can claim the same email. + other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "") + if err != nil { + t.Fatalf("provision other: %v", err) + } + if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("email should be free after deletion, got: %v", err) + } +} + +// TestDeletionDossierReaders: after deletion the admin readers expose the credential +// journal and the tombstone dossier. +func TestDeletionDossierReaders(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00") + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + rets, err := store.RetainedIdentities(ctx, acc.ID) + if err != nil || len(rets) != 2 { + t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err) + } + for _, r := range rets { + if r.Reason != "delete" { + t.Errorf("retained reason = %q, want delete", r.Reason) + } + } + info, err := store.DeletionInfo(ctx, acc.ID) + if err != nil { + t.Fatalf("DeletionInfo: %v", err) + } + if info.DeletedAt == nil { + t.Error("DeletionInfo.DeletedAt should be set") + } + if info.DeletedDisplayName != "Иван" { + t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName) + } +} + +// listHasID reports whether the user list contains accountID. +func listHasID(items []account.UserListItem, id uuid.UUID) bool { + for _, it := range items { + if it.ID == id { + return true + } + } + return false +} + +// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and +// shown only under the Deleted scope. +func TestUserListDeletedFilter(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "") + if err != nil { + t.Fatalf("provision live: %v", err) + } + goneTg := "tg-" + uuid.NewString() + gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "") + if err != nil { + t.Fatalf("provision gone: %v", err) + } + goneEmail := "gone-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil { + t.Fatalf("attach gone email: %v", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0) + if err != nil { + t.Fatalf("list people: %v", err) + } + if listHasID(people, gone.ID) { + t.Error("a deleted account must not appear in the default People list") + } + if !listHasID(people, live.ID) { + t.Error("a live account must appear in the default People list") + } + deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0) + if err != nil { + t.Fatalf("list deleted: %v", err) + } + if !listHasID(deleted, gone.ID) { + t.Error("a deleted account must appear in the Deleted list") + } + if listHasID(deleted, live.ID) { + t.Error("a live account must not appear in the Deleted list") + } + + // A search spans both lists and reaches the retention journal: a deleted account is + // still found by the email and external id it held (both moved to retained_identities + // on deletion, out of the live identities table). + byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0) + if err != nil { + t.Fatalf("search by email: %v", err) + } + if !listHasID(byEmail, gone.ID) { + t.Error("a deleted account must be found by the email it held (retention journal)") + } + byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0) + if err != nil { + t.Fatalf("search by external id: %v", err) + } + if !listHasID(byExt, gone.ID) { + t.Error("a deleted account must be found by the external id it held (retention journal)") + } +} + +// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to +// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set). +func TestConfirmCodeClearsGuest(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + + guest, err := store.ProvisionGuest(ctx, "") + if err != nil { + t.Fatalf("provision guest: %v", err) + } + email := "cc-" + uuid.NewString() + "@example.com" + if err := svc.RequestCode(ctx, guest.ID, email); err != nil { + t.Fatalf("request code: %v", err) + } + if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil { + t.Fatalf("confirm code: %v", err) + } + after, err := store.GetByID(ctx, guest.ID) + if err != nil { + t.Fatalf("load: %v", err) + } + if after.IsGuest { + t.Error("confirming an email must clear the guest flag") + } +} + +// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human +// opponent. +func TestDropAllRobotGames(t *testing.T) { + ctx := context.Background() + gsvc := newGameService() + robots := newRobotService(t, gsvc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + mm := newMatchmaker(t, robots, time.Minute, 0) + deleter := accountdelete.NewDeleter(testDB) + + user := provisionAccount(t) + other := provisionAccount(t) + + aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("start vs AI: %v", err) + } + humanGame, err := gsvc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1, + }) + if err != nil { + t.Fatalf("create human game: %v", err) + } + + n, err := deleter.DropAllRobotGames(ctx, user) + if err != nil { + t.Fatalf("drop: %v", err) + } + if n != 1 { + t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n) + } + if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil { + t.Error("the vs-AI game should be dropped") + } + if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil { + t.Errorf("the human game should be kept, got: %v", err) + } +} + +// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no +// deeplink in the mail); a platform-only account has no email and cannot request a code. +func TestDeleteStepUpEmail(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has { + t.Fatalf("HasEmail = (%v, %v), want true", has, err) + } + if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil { + t.Fatalf("request delete code: %v", err) + } + if strings.Contains(mailer.lastBody, "/confirm/") { + t.Error("a delete email must not carry a one-tap deeplink") + } + code := sixDigit.FindString(mailer.lastBody) + if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil { + t.Error("a wrong delete code must be rejected") + } + if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil { + t.Fatalf("verify correct delete code: %v", err) + } + + noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision no-email: %v", err) + } + if has, _ := svc.HasEmail(ctx, noEmail.ID); has { + t.Error("HasEmail must be false for a platform-only account") + } + if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) { + t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err) + } +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go new file mode 100644 index 0000000..68cafd8 --- /dev/null +++ b/backend/internal/inttest/retention_test.go @@ -0,0 +1,192 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" +) + +// lastLoginIP reads an account's stamped last-login IP ("" when unset). +func lastLoginIP(t *testing.T, accountID uuid.UUID) string { + t.Helper() + var ip sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip) + if err != nil { + t.Fatalf("read last_login_ip %s: %v", accountID, err) + } + return ip.String +} + +// retainedRow is one row of the retention journal, read directly for assertions. +type retainedRow struct { + kind, externalID, reason string +} + +// retainedRows reads the retention journal for an account, oldest detach first. +func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow { + t.Helper() + rows, err := testDB.QueryContext(context.Background(), + "SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at", + accountID) + if err != nil { + t.Fatalf("query retained_identities %s: %v", accountID, err) + } + defer rows.Close() + var out []retainedRow + for rows.Next() { + var r retainedRow + if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil { + t.Fatalf("scan retained row: %v", err) + } + out = append(out, r) + } + return out +} + +// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention +// journal (reason=unlink) before the live identity is removed. +func TestUnlinkJournalsRetainedIdentity(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + tgExt := "tg-" + uuid.NewString() + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) + if err != nil { + t.Fatalf("provision: %v", err) + } + email := "keep-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("attach email: %v", err) + } + + if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { + t.Fatalf("unlink telegram: %v", err) + } + got := retainedRows(t, acc.ID) + if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" { + t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt) + } +} + +// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the +// retention journal (reason=change). +func TestChangeEmailJournalsOldAddress(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + oldAddr := "old-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil { + t.Fatalf("attach old email: %v", err) + } + newAddr := "new-" + uuid.NewString() + "@example.com" + if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil { + t.Fatalf("request change: %v", err) + } + if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil { + t.Fatalf("confirm change: %v", err) + } + got := retainedRows(t, acc.ID) + if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" { + t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr) + } +} + +// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within +// the hour is a no-op (throttled), so it costs at most one write per account per hour. +func TestStampLastLoginThrottles(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil { + t.Fatalf("first stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("first stamp ip = %q, want 1.2.3.4", got) + } + if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil { + t.Fatalf("second stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) + } +} + +// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges +// older ones, and drops a long-deleted account's feedback thread + dossier PII. +func TestReapExpiredRetention(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + // An unlinked provider leaves a journal row detached "now". + tgExt := "tg-" + uuid.NewString() + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { + t.Fatalf("unlink: %v", err) + } + // A cutoff before the detach keeps the row. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil { + t.Fatalf("reap (early cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 1 { + t.Fatalf("journal after early-cutoff reap = %+v, want kept", got) + } + // A cutoff after the detach purges it. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("reap (late cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 0 { + t.Fatalf("journal after late-cutoff reap = %+v, want purged", got) + } + + // A deleted account past the cutoff loses its feedback thread and dossier PII. + del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "") + if err != nil { + t.Fatalf("provision deletee: %v", err) + } + if _, err := testDB.ExecContext(ctx, + "INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')", + uuid.New(), del.ID); err != nil { + t.Fatalf("insert feedback: %v", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 { + t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err) + } + if name, _ := deletedFields(t, del.ID); name != "" { + t.Errorf("deleted_display_name after reap = %q, want cleared", name) + } + var fbCount int + if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil { + t.Fatalf("count feedback: %v", err) + } + if fbCount != 0 { + t.Errorf("feedback rows after reap = %d, want 0", fbCount) + } +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index c69445d..b091195 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -32,4 +32,8 @@ type Accounts struct { MergedAt *time.Time FlaggedHighRateAt *time.Time VariantPreferences pq.StringArray + LastLoginAt *time.Time + LastLoginIP *string + DeletedAt *time.Time + DeletedDisplayName *string } diff --git a/backend/internal/postgres/jet/backend/model/retained_identities.go b/backend/internal/postgres/jet/backend/model/retained_identities.go new file mode 100644 index 0000000..15d0e36 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/retained_identities.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type RetainedIdentities struct { + RetainedID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Kind string + ExternalID string + Confirmed bool + LinkedAt time.Time + DetachedAt time.Time + Reason string +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 3cafd35..8ca2ca5 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -35,6 +35,10 @@ type accountsTable struct { MergedAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz VariantPreferences postgres.ColumnStringArray + LastLoginAt postgres.ColumnTimestampz + LastLoginIP postgres.ColumnString + DeletedAt postgres.ColumnTimestampz + DeletedDisplayName postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAtColumn = postgres.TimestampzColumn("merged_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} + LastLoginAtColumn = postgres.TimestampzColumn("last_login_at") + LastLoginIPColumn = postgres.StringColumn("last_login_ip") + DeletedAtColumn = postgres.TimestampzColumn("deleted_at") + DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn} ) @@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAt: MergedAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn, VariantPreferences: VariantPreferencesColumn, + LastLoginAt: LastLoginAtColumn, + LastLoginIP: LastLoginIPColumn, + DeletedAt: DeletedAtColumn, + DeletedDisplayName: DeletedDisplayNameColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/jet/backend/table/retained_identities.go b/backend/internal/postgres/jet/backend/table/retained_identities.go new file mode 100644 index 0000000..17851eb --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/retained_identities.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "") + +type retainedIdentitiesTable struct { + postgres.Table + + // Columns + RetainedID postgres.ColumnString + AccountID postgres.ColumnString + Kind postgres.ColumnString + ExternalID postgres.ColumnString + Confirmed postgres.ColumnBool + LinkedAt postgres.ColumnTimestampz + DetachedAt postgres.ColumnTimestampz + Reason postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type RetainedIdentitiesTable struct { + retainedIdentitiesTable + + EXCLUDED retainedIdentitiesTable +} + +// AS creates new RetainedIdentitiesTable with assigned alias +func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new RetainedIdentitiesTable with assigned schema name +func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix +func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix +func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable { + return &RetainedIdentitiesTable{ + retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias), + EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""), + } +} + +func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable { + var ( + RetainedIDColumn = postgres.StringColumn("retained_id") + AccountIDColumn = postgres.StringColumn("account_id") + KindColumn = postgres.StringColumn("kind") + ExternalIDColumn = postgres.StringColumn("external_id") + ConfirmedColumn = postgres.BoolColumn("confirmed") + LinkedAtColumn = postgres.TimestampzColumn("linked_at") + DetachedAtColumn = postgres.TimestampzColumn("detached_at") + ReasonColumn = postgres.StringColumn("reason") + allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn} + ) + + return retainedIdentitiesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + RetainedID: RetainedIDColumn, + AccountID: AccountIDColumn, + Kind: KindColumn, + ExternalID: ExternalIDColumn, + Confirmed: ConfirmedColumn, + LinkedAt: LinkedAtColumn, + DetachedAt: DetachedAtColumn, + Reason: ReasonColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 19113ed..540bcec 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -34,6 +34,7 @@ func UseSchema(schema string) { GameSetupDraws = GameSetupDraws.FromSchema(schema) Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) + RetainedIdentities = RetainedIdentities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) SuspensionReasons = SuspensionReasons.FromSchema(schema) } diff --git a/backend/internal/postgres/migrations/00007_account_deletion_retention.sql b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql new file mode 100644 index 0000000..9cfa130 --- /dev/null +++ b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql @@ -0,0 +1,47 @@ +-- Account deletion as legal retention (not erasure). Two additive pieces. +-- +-- retained_identities is an append-only journal of every credential detached from an +-- account — on unlink, email change, or account deletion (reason). It preserves the legal +-- dossier (which email/vk/tg was linked, and when) while the live identities row is +-- removed, so the (kind, external_id) frees for a new account to reuse. No unique +-- constraint and no kind CHECK: the same credential may recur across accounts and events, +-- and the log stays robust to future identity kinds. detached_at drives the retention TTL. +-- +-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled), +-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for +-- the admin dossier after the live display_name is scrubbed to the anonymised label). +-- +-- Expand-contract: everything is additive (a new table plus nullable columns), so a +-- backend image rollback stays DB-safe — older code simply ignores them. The accounts +-- table shape changes, so its generated go-jet model is regenerated. + +-- +goose Up +CREATE TABLE backend.retained_identities ( + retained_id uuid NOT NULL, + account_id uuid NOT NULL, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean DEFAULT false NOT NULL, + linked_at timestamp with time zone NOT NULL, + detached_at timestamp with time zone DEFAULT now() NOT NULL, + reason text NOT NULL, + CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id), + CONSTRAINT retained_identities_reason_chk + CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text]))) +); +CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id); +CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at); + +ALTER TABLE backend.accounts + ADD COLUMN last_login_at timestamp with time zone, + ADD COLUMN last_login_ip text, + ADD COLUMN deleted_at timestamp with time zone, + ADD COLUMN deleted_display_name text; + +-- +goose Down +ALTER TABLE backend.accounts + DROP COLUMN last_login_at, + DROP COLUMN last_login_ip, + DROP COLUMN deleted_at, + DROP COLUMN deleted_display_name; +DROP TABLE backend.retained_identities; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dc442a6..6d55e1e 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -80,6 +80,10 @@ func (s *Server) registerRoutes() { // refused without disclosure, never merged). u.POST("/link/email/change/request", s.handleChangeEmailRequest) u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm) + // Account deletion (legal retention, not erasure): step-up via a mailed code + // (email accounts) or a typed phrase (platform-only), then tombstone + free creds. + u.POST("/delete/request", s.handleRequestDelete) + u.POST("/delete/confirm", s.handleConfirmDelete) } if s.games != nil { u.GET("/games", s.handleListGames) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index affda86..48076fe 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -62,6 +62,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) gm.POST("/users/:id/remove-email", s.consoleRemoveEmail) + gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons/:id/update", s.consoleUpdateReason) @@ -133,6 +134,7 @@ func (s *Server) consoleUsers(c *gin.Context) { page := consolePage(c) filter := account.UserFilter{ Robots: c.Query("kind") == "robots", + Deleted: c.Query("kind") == "deleted", NameMask: c.Query("name"), ExternalIDMask: c.Query("ext"), EmailExact: c.Query("email"), @@ -147,6 +149,9 @@ func (s *Server) consoleUsers(c *gin.Context) { if filter.Robots { q.Set("kind", "robots") } + if filter.Deleted { + q.Set("kind", "deleted") + } if strings.TrimSpace(filter.NameMask) != "" { q.Set("name", filter.NameMask) } @@ -158,21 +163,24 @@ func (s *Server) consoleUsers(c *gin.Context) { } view := adminconsole.UsersView{ Pager: adminconsole.NewPager(page, adminPageSize, total), - Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, + Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, EmailExact: filter.EmailExact, FilterQuery: template.URL(q.Encode()), } ids := make([]uuid.UUID, 0, len(items)) for _, it := range items { kind := "registered" - if it.IsRobot { + switch { + case it.IsRobot: kind = "robot" - } else if it.IsGuest { + case it.IsDeleted: + kind = "deleted" + case it.IsGuest: kind = "guest" } view.Items = append(view.Items, adminconsole.UserRow{ ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind, - Language: it.PreferredLanguage, Guest: it.IsGuest, + Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted, FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt), }) ids = append(ids, it.ID) @@ -368,6 +376,25 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)}) } } + if info, err := s.accounts.DeletionInfo(ctx, id); err == nil { + if info.LastLoginAt != nil { + view.LastLoginAt = fmtTime(*info.LastLoginAt) + } + view.LastLoginIP = info.LastLoginIP + if info.DeletedAt != nil { + view.Deleted = true + view.DeletedAt = fmtTime(*info.DeletedAt) + view.DeletedName = info.DeletedDisplayName + } + } + if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil { + for _, r := range rets { + view.Retained = append(view.Retained, adminconsole.RetainedRow{ + Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason, + Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt), + }) + } + } if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil { view.TelegramID = tg } @@ -979,6 +1006,22 @@ func (s *Server) consoleRemoveEmail(c *gin.Context) { } } +// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent +// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its +// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked. +func (s *Server) consoleDeleteUser(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + if err := s.deleteAccount(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back) +} + // consoleBlockUser manually blocks an account: it records the suspension (permanent or until a // parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active // games, removing them from matchmaking. The block takes effect on the player's next request. diff --git a/backend/internal/server/handlers_delete.go b/backend/internal/server/handlers_delete.go new file mode 100644 index 0000000..9bd9da8 --- /dev/null +++ b/backend/internal/server/handlers_delete.go @@ -0,0 +1,131 @@ +package server + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/accountdelete" + "scrabble/backend/internal/game" +) + +// Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account +// row survives as a tombstone while its credentials are journalled + freed, its live +// surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The +// step-up is a mailed code for an account with a confirmed email, or a typed phrase for a +// platform-only account (possession-proof is unattainable there, so the phrase is +// anti-impulse only). + +// deletePhrase is the fixed confirmation phrase a no-email account types to delete. +// Compared case-insensitively; the client localises only the surrounding instruction. +const deletePhrase = "DELETE" + +// deleteRequestResponse tells the client which step-up the account uses. +type deleteRequestResponse struct { + Method string `json:"method"` // "email" | "phrase" +} + +// handleRequestDelete starts account deletion: it mails a delete code to an account with a +// confirmed email, or reports the typed-phrase path otherwise. +func (s *Server) handleRequestDelete(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ctx := c.Request.Context() + hasEmail, err := s.emails.HasEmail(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if !hasEmail { + c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"}) + return + } + if err := s.emails.RequestDeleteCode(ctx, uid); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"}) +} + +// deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed +// phrase (platform-only account). +type deleteConfirmBody struct { + Code string `json:"code"` + Phrase string `json:"phrase"` +} + +// handleConfirmDelete verifies the step-up and deletes the account. +func (s *Server) handleConfirmDelete(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req deleteConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + ctx := c.Request.Context() + hasEmail, err := s.emails.HasEmail(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if hasEmail { + if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil { + s.abortErr(c, err) + return + } + } else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) { + abortBadRequest(c, "confirmation phrase does not match") + return + } + if err := s.deleteAccount(ctx, uid); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// deleteAccount runs the deletion orchestration after the step-up passed: it resigns the +// account's active games (so opponents are not stranded and robot games end cleanly), +// drops its all-robot games, tombstones + anonymises the account (journalling and freeing +// its credentials), and revokes its sessions. The tombstone is the point of no return — +// its failure aborts; the game cleanup and session revocation around it are best-effort. +func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error { + deleter := accountdelete.NewDeleter(s.db) + if s.games != nil { + if games, err := s.games.ListForAccount(ctx, uid); err == nil { + for _, g := range games { + if g.Status != game.StatusActive { + continue + } + if _, err := s.games.Resign(ctx, g.ID, uid); err != nil { + s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err)) + } + } + } else { + s.log.Warn("delete: list games failed", zap.Error(err)) + } + } + if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil { + s.log.Warn("delete: drop all-robot games failed", zap.Error(err)) + } + if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil { + return err + } + if s.sessions != nil { + if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil { + s.log.Warn("delete: revoke sessions failed", zap.Error(err)) + } + } + return nil +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 9faf71e..f5bfaca 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } + // The SPA fetches the profile once per cold app-load, so stamp the account's last + // login time and client IP here (throttled to at most once an hour). Best-effort: it + // feeds the deletion dossier, never blocks the profile read. + _ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c)) c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f1376c5..b80bedc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -273,6 +273,25 @@ arrive from a platform rather than completing a mandatory registration). new address already confirmed by **another** account is refused **without disclosure** (a neutral "check the address or contact support") and **never merged** — the anti- enumeration check is only reachable by someone who controls the new mailbox. +- **Deletion is legal retention, not erasure** (`internal/accountdelete`). The account row + survives as a **tombstone** (`accounts.deleted_at`) — its chat/complaint foreign keys + have no cascade, so a hard delete is impossible. `AnonymizeAndTombstone` **journals** + every live identity into an append-only **`retained_identities`** log (the legal dossier) + then removes it, freeing the `(kind, external_id)` for a new account to reuse; it scrubs + the live `display_name` → **`[Deleted]`** (an unspoofable sentinel — the editable-name + rule forbids brackets) while snapshotting the real name into `deleted_display_name`, + anonymises the game-seat snapshots, and drops the account's own friendships / blocks / + invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are + kept.** The orchestration a layer up resigns the account's active games (so opponents are + not stranded), **drops its all-robot games** (no human opponent; children cascade), and + revokes its sessions. Every credential detachment — **unlink, email change and delete** — + writes a `retained_identities` row (`reason`), so the dossier keeps the full timeline. + Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete; + `ConfirmByToken` refuses a delete token) for an email account, else a typed phrase. + `last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled + to once an hour). A **two-year TTL reaper** (from the event) purges the whole dossier — + the journal, plus a deleted account's feedback thread and dossier PII — while the chat and + the tombstone row stay. - **Merge** retires the account that owns the linked identity into the **current** account, in a single transaction (`internal/accountmerge`): statistics summed (counters incl. moves/hints added, max points kept, and the per-variant best moves diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c517b61..08feae1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -117,6 +117,16 @@ address that already belongs to another account is refused with a neutral "check address or contact support" — the switch never merges and never reveals the other account. +A player can **delete their account**. This is a legal-retention removal, not an erasure: +the account is deactivated and its live surfaces anonymised (opponents see "[Deleted]"), its +sign-in methods are freed for reuse, and its sessions are revoked — but a dossier (the +credentials that were linked, the last login, and the player's messages) is retained for +the operator and purged after two years. Confirming deletion needs a mailed code for an +account with an email, or a typed phrase otherwise. Active games are forfeited so opponents +are not stranded; solo games against the AI are removed, while games with a human opponent +are kept under the anonymised name and the player's chat stays. Reopening the app after +deletion simply creates a fresh account. + ### Lobby & matchmaking On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1d9c52b..e06dd6e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -121,6 +121,15 @@ Telegram; добавление VK в вебе пока не предлагает «проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает аккаунты и не раскрывает чужой. +Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание: +аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»), +способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье +(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и +чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть +e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные +игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока +остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт. + ### Лобби и подбор При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 164edf0..7a79288 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -351,6 +351,26 @@ func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code str return out, err } +// DeleteRequestResp reports which account-deletion step-up the account uses. +type DeleteRequestResp struct { + Method string `json:"method"` +} + +// DeleteRequest starts account deletion: the backend mails a delete code (email accounts) +// or reports the typed-phrase path. +func (c *Client) DeleteRequest(ctx context.Context, userID string) (DeleteRequestResp, error) { + var out DeleteRequestResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/delete/request", userID, "", nil, &out) + return out, err +} + +// DeleteConfirm verifies the step-up (a mailed code or the typed phrase) and deletes the +// account. +func (c *Client) DeleteConfirm(ctx context.Context, userID, code, phrase string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/delete/confirm", userID, "", + map[string]string{"code": code, "phrase": phrase}, nil) +} + // LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller // and returns the refreshed profile in the result. func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 8cb2562..afc5434 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -37,6 +37,17 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } +// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which +// deletion step-up the account uses ("email" | "phrase"). +func encodeDeleteRequestResult(method string) []byte { + b := flatbuffers.NewBuilder(32) + m := b.CreateString(method) + fb.AccountDeleteRequestResultStart(b) + fb.AccountDeleteRequestResultAddMethod(b, m) + b.Finish(fb.AccountDeleteRequestResultEnd(b)) + return b.FinishedBytes() +} + // encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the // minted Session for a login. All strings and the nested Session table are built // before the result table is opened. diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 5d6d3af..72f2c70 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -21,6 +21,8 @@ const ( MsgLinkUnlink = "link.unlink" MsgEmailChangeRequest = "link.email.change.request" MsgEmailChangeConfirm = "link.email.change.confirm" + MsgAccountDeleteReq = "account.delete.request" + MsgAccountDeleteConf = "account.delete.confirm" ) // registerLinkOps adds the linking & merge operations. The telegram ops need the @@ -32,6 +34,8 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true} r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteReq] = Op{Handler: deleteRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteConf] = Op{Handler: deleteConfirmHandler(backend), Auth: true} if tg != nil { r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} @@ -94,6 +98,28 @@ func changeEmailConfirmHandler(backend *backendclient.Client) Handler { } } +// deleteRequestHandler starts account deletion, returning which step-up the account uses. +func deleteRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.DeleteRequest(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeDeleteRequestResult(res.Method), nil + } +} + +// deleteConfirmHandler verifies the step-up proof and deletes the account. +func deleteConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsAccountDeleteConfirm(req.Payload, 0) + if err := backend.DeleteConfirm(ctx, req.UserID, string(in.Code()), string(in.Phrase())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + // linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and // returns the refreshed link result (status "unlinked" + the updated profile). func linkUnlinkHandler(backend *backendclient.Client) Handler { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 758a212..c078538 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -507,6 +507,19 @@ table LinkUnlinkRequest { kind:string; } +// AccountDeleteConfirm carries the account-deletion step-up proof: a mailed code (email +// accounts) or the typed phrase (platform-only accounts). +table AccountDeleteConfirm { + code:string; + phrase:string; +} + +// AccountDeleteRequestResult reports which deletion step-up the account uses: "email" +// (a code was mailed) or "phrase" (type the confirmation phrase). +table AccountDeleteRequestResult { + method:string; +} + // LinkResult is the unified result of a confirm or merge step. status is "linked" // (bound to the caller), "merge_required" (the identity belongs to another account — // the secondary_* fields summarise it for the irreversible confirmation), or diff --git a/pkg/fbs/scrabblefb/AccountDeleteConfirm.go b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go new file mode 100644 index 0000000..e28fa49 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteConfirm struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteConfirm) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteConfirm) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteConfirm) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AccountDeleteConfirm) Phrase() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteConfirmStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func AccountDeleteConfirmAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func AccountDeleteConfirmAddPhrase(builder *flatbuffers.Builder, phrase flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(phrase), 0) +} +func AccountDeleteConfirmEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go new file mode 100644 index 0000000..40837b4 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteRequestResult struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteRequestResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteRequestResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteRequestResult) Method() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteRequestResultStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func AccountDeleteRequestResultAddMethod(builder *flatbuffers.Builder, method flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(method), 0) +} +func AccountDeleteRequestResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 69be846..ac06423 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -345,6 +345,20 @@ test('link then unlink Telegram from the sign-in methods', async ({ page }) => { await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); +test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => { + await loginLobby(page); + await openProfile(page); + + // The mock profile holds an email, so deletion asks for the mailed code. + await page.getByRole('button', { name: 'Delete account' }).click(); + await expect(page.getByText('Delete your account?')).toBeVisible(); + await page.getByRole('dialog').locator('.codein').fill('123456'); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + // The app swaps to the terminal account-deleted screen. + await expect(page.getByText('Account deleted')).toBeVisible(); +}); + test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 215f47b..a1b8824 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -20,6 +20,7 @@ import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; + import AccountDeleted from './screens/AccountDeleted.svelte'; import BootError from './screens/BootError.svelte'; import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; @@ -83,6 +84,8 @@ +{:else if app.accountDeleted} + {:else if app.blocked} {:else} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index ce02ba1..73aa84a 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -1,5 +1,7 @@ // automatically generated by the FlatBuffers compiler, do not modify +export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js'; +export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; diff --git a/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts b/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts new file mode 100644 index 0000000..f0f120b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AccountDeleteConfirm { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteConfirm { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm { + return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +code():string|null +code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +code(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +phrase():string|null +phrase(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +phrase(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startAccountDeleteConfirm(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, codeOffset, 0); +} + +static addPhrase(builder:flatbuffers.Builder, phraseOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, phraseOffset, 0); +} + +static endAccountDeleteConfirm(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccountDeleteConfirm(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, phraseOffset:flatbuffers.Offset):flatbuffers.Offset { + AccountDeleteConfirm.startAccountDeleteConfirm(builder); + AccountDeleteConfirm.addCode(builder, codeOffset); + AccountDeleteConfirm.addPhrase(builder, phraseOffset); + return AccountDeleteConfirm.endAccountDeleteConfirm(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts b/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts new file mode 100644 index 0000000..1c92dae --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AccountDeleteRequestResult { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteRequestResult { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult { + return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +method():string|null +method(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +method(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startAccountDeleteRequestResult(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addMethod(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, methodOffset, 0); +} + +static endAccountDeleteRequestResult(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccountDeleteRequestResult(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset):flatbuffers.Offset { + AccountDeleteRequestResult.startAccountDeleteRequestResult(builder); + AccountDeleteRequestResult.addMethod(builder, methodOffset); + return AccountDeleteRequestResult.endAccountDeleteRequestResult(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2cce2a4..38593c7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -13,6 +13,7 @@ import { startLocalEvalMetrics } from './localeval-metrics'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme'; import { insideTelegram, + telegramClose, collectTelegramDiag, type TelegramDiag, onTelegramPath, @@ -32,7 +33,7 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; +import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; @@ -92,6 +93,9 @@ export const app = $state<{ /** The caller's active manual block, or null. When set, App.svelte replaces every screen with * the terminal blocked screen and all push/poll is stopped. */ blocked: BlockStatus | null; + /** Set once the account has been deleted: App.svelte shows the terminal "account deleted" + * screen and all push/poll is stopped. Reopening the app just creates a fresh account. */ + accountDeleted: boolean; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; @@ -140,6 +144,7 @@ export const app = $state<{ session: null, profile: null, blocked: null, + accountDeleted: false, toast: null, lastEvent: null, theme: 'auto', @@ -544,6 +549,39 @@ export async function applyLinkResult(r: LinkResult): Promise { void persistLanguageToServer(app.locale); } +/** + * requestDeleteAccount starts account deletion and reports which step-up the account uses: + * 'email' (a code was mailed) or 'phrase' (type the confirmation phrase). + */ +export async function requestDeleteAccount(): Promise<'email' | 'phrase'> { + return (await gateway.deleteRequest()).method; +} + +/** + * confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On + * success it switches to the terminal "account deleted" screen and stops all push/poll; it + * throws on a bad code/phrase so the caller can let the user retry. + */ +export async function confirmDeleteAccount(code: string, phrase: string): Promise { + await gateway.deleteConfirm(code, phrase); + closeStream(); + app.accountDeleted = true; +} + +/** + * closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK). + * A plain browser has nothing to close, so it is a no-op there — the terminal screen stays. + */ +export function closeDeletedApp(): void { + if (insideTelegram()) { + telegramClose(); + return; + } + if (insideVK()) { + void vkClose(); + } +} + /** * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 3769da8..f6a235c 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -185,6 +185,11 @@ export interface GatewayClient { * refused without disclosure. */ changeEmailRequest(email: string): Promise; changeEmailConfirm(email: string, code: string): Promise; + /** Start account deletion; the backend mails a code (method 'email') or asks for the + * typed phrase (method 'phrase'). */ + deleteRequest(): Promise<{ method: 'email' | 'phrase' }>; + /** Confirm and perform account deletion with the mailed code or the typed phrase. */ + deleteConfirm(code: string, phrase: string): Promise; // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 377921a..82d13c6 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -27,6 +27,8 @@ import { encodeEnqueue, encodeExchange, encodeExportUrlRequest, + encodeAccountDeleteConfirm, + decodeDeleteRequestResult, encodeGuestLogin, encodeLinkUnlink, encodeStateRequest, @@ -464,6 +466,21 @@ describe('codec', () => { expect(r.kind()).toBe('telegram'); }); + it('round-trips the account-delete confirm + request-result wire', () => { + const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm( + new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')), + ); + expect(c.code()).toBe('123456'); + expect(c.phrase()).toBe('DELETE'); + + const b = new Builder(32); + const m = b.createString('email'); + fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b); + fb.AccountDeleteRequestResult.addMethod(b, m); + b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b)); + expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email'); + }); + it('passes an unlinked / changed LinkResult status straight through', () => { for (const status of ['unlinked', 'changed'] as const) { const b = new Builder(64); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 6ff6739..4c34194 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -739,6 +739,21 @@ export function encodeLinkUnlink(kind: string): Uint8Array { return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b)); } +export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array { + const b = new Builder(64); + const c = b.createString(code); + const p = b.createString(phrase); + fb.AccountDeleteConfirm.startAccountDeleteConfirm(b); + fb.AccountDeleteConfirm.addCode(b, c); + fb.AccountDeleteConfirm.addPhrase(b, p); + return finish(b, fb.AccountDeleteConfirm.endAccountDeleteConfirm(b)); +} + +export function decodeDeleteRequestResult(buf: Uint8Array): { method: 'email' | 'phrase' } { + const r = fb.AccountDeleteRequestResult.getRootAsAccountDeleteRequestResult(new ByteBuffer(buf)); + return { method: (s(r.method()) || 'phrase') as 'email' | 'phrase' }; +} + export function decodeLinkResult(buf: Uint8Array): LinkResult { const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf)); const sess = r.session(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9796b94..5aef200 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -188,6 +188,15 @@ export const en = { 'profile.unlinked': 'Account unlinked.', 'profile.unlinkTitle': 'Unlink account?', 'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.', + 'profile.deleteAccount': 'Delete account', + 'profile.deleteTitle': 'Delete your account?', + 'profile.deleteWarn': 'This removes your account and frees your sign-in methods. It cannot be undone.', + 'profile.deleteEmailPrompt': 'We sent a code to your email. Enter it to delete your account.', + 'profile.deletePhrasePrompt': 'Type DELETE to confirm.', + 'profile.deletePhrasePlaceholder': 'DELETE', + 'profile.deleteConfirm': 'Delete permanently', + 'deleted.title': 'Account deleted', + 'deleted.body': 'Your account has been removed. Reopening the app just creates a new account.', 'settings.title': 'Settings', 'settings.theme': 'Theme', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9b5503c..9023d99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -188,6 +188,15 @@ export const ru: Record = { 'profile.unlinked': 'Аккаунт отвязан.', 'profile.unlinkTitle': 'Отвязать аккаунт?', 'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.', + 'profile.deleteAccount': 'Удалить аккаунт', + 'profile.deleteTitle': 'Удалить аккаунт?', + 'profile.deleteWarn': 'Аккаунт будет удалён, а способы входа освобождены. Отменить нельзя.', + 'profile.deleteEmailPrompt': 'Мы отправили код на вашу почту. Введите его, чтобы удалить аккаунт.', + 'profile.deletePhrasePrompt': 'Введите DELETE для подтверждения.', + 'profile.deletePhrasePlaceholder': 'DELETE', + 'profile.deleteConfirm': 'Удалить навсегда', + 'deleted.title': 'Учётная запись удалена', + 'deleted.body': 'Аккаунт удалён. Если снова откроете приложение — будет создан новый аккаунт.', 'settings.title': 'Настройки', 'settings.theme': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 28649af..f0d27cc 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -687,6 +687,10 @@ export class MockGateway implements GatewayClient { this.profile.email = email; return { ...emptyLinked(), status: 'changed' }; } + async deleteRequest(): Promise<{ method: 'email' | 'phrase' }> { + return { method: this.profile.email ? 'email' : 'phrase' }; + } + async deleteConfirm(_code: string, _phrase: string): Promise {} async statsGet(): Promise { return { ...this.stats }; } diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index e9ad334..74f23da 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -36,6 +36,7 @@ interface TelegramWebApp { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; + close?: () => void; openTelegramLink?: (url: string) => void; openLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; @@ -84,6 +85,12 @@ export function insideTelegram(): boolean { return !!w && typeof w.initData === 'string' && w.initData.length > 0; } +/** telegramClose closes the Mini App (Telegram.WebApp.close), used on the terminal + * account-deleted screen. A no-op outside Telegram. */ +export function telegramClose(): void { + webApp()?.close?.(); +} + // The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the // version the official Mini Apps page currently recommends so newer client features (fullscreen, // safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs. diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 99bf7df..12863af 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -277,6 +277,12 @@ export function createTransport(baseUrl: string): GatewayClient { async changeEmailConfirm(email, code) { return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code))); }, + async deleteRequest() { + return codec.decodeDeleteRequestResult(await exec('account.delete.request', codec.empty())); + }, + async deleteConfirm(code, phrase) { + await exec('account.delete.confirm', codec.encodeAccountDeleteConfirm(code, phrase)); + }, async statsGet() { return codec.decodeStats(await exec('stats.get', codec.empty())); }, diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index aa236ff..2fb04fe 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -52,6 +52,18 @@ export async function vkInit(): Promise { } } +/** + * vkClose closes the VK Mini App (VKWebAppClose), used on the terminal account-deleted + * screen. Best-effort: a no-op outside VK. + */ +export async function vkClose(): Promise { + try { + await (await bridge()).send('VKWebAppClose', { status: 'success' }); + } catch { + // Outside VK there is no client to receive it. + } +} + /** * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back diff --git a/ui/src/screens/AccountDeleted.svelte b/ui/src/screens/AccountDeleted.svelte new file mode 100644 index 0000000..372e71f --- /dev/null +++ b/ui/src/screens/AccountDeleted.svelte @@ -0,0 +1,53 @@ + + +
+
+

{t('deleted.title')}

+

{t('deleted.body')}

+ {#if canClose} + + {/if} +
+
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index db1b42a..9cc9a3f 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -1,7 +1,15 @@
@@ -381,6 +416,10 @@ {/if} + {#if !p.isGuest} + + {/if} + @@ -409,6 +448,28 @@ {/if} +{#if deleting} + {@const d = deleting} + (deleting = null)}> +

{t('profile.deleteWarn')}

+ {#if d.method === 'email'} +

{t('profile.deleteEmailPrompt')}

+
+ +
+ {:else} +

{t('profile.deletePhrasePrompt')}

+
+ +
+ {/if} +
+ + +
+
+{/if} +
AccountFriends since
{{.ID}}{{.DisplayName}}{{if .Guest}} guest{{end}}{{if .FlaggedHighRate}} high-rate{{end}}{{.DisplayName}}{{if .Deleted}} deleted{{end}}{{if .Guest}} guest{{end}}{{if .FlaggedHighRate}} high-rate{{end}} {{.Kind}} {{.Language}} {{.CreatedAt}}