feat(account): deletion = legal retention, not erasure (PR3) #164
@@ -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.
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -107,6 +107,24 @@
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Deletion & retention</h2>
|
||||
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
|
||||
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
|
||||
{{if .Retained}}
|
||||
<h3>Retention journal (legal dossier of detached credentials)</h3>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if not .Deleted}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
||||
<button type="submit">Delete user</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
||||
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
|
||||
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
|
||||
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||
</nav>
|
||||
<form class="form" method="get" action="/_gm/users">
|
||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
|
||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
|
||||
@@ -18,7 +19,7 @@
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,15 @@ Telegram; добавление VK в вебе пока не предлагает
|
||||
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
|
||||
аккаунты и не раскрывает чужой.
|
||||
|
||||
Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание:
|
||||
аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»),
|
||||
способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье
|
||||
(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и
|
||||
чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть
|
||||
e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные
|
||||
игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока
|
||||
остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт.
|
||||
|
||||
### Лобби и подбор
|
||||
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
||||
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
||||
show the retry screen instead of falling back to the web login. -->
|
||||
<BootError />
|
||||
{:else if app.accountDeleted}
|
||||
<AccountDeleted />
|
||||
{:else if app.blocked}
|
||||
<Blocked />
|
||||
{:else}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -185,6 +185,11 @@ export interface GatewayClient {
|
||||
* refused without disclosure. */
|
||||
changeEmailRequest(email: string): Promise<void>;
|
||||
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
|
||||
/** 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<void>;
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -188,6 +188,15 @@ export const ru: Record<MessageKey, string> = {
|
||||
'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': 'Тема',
|
||||
|
||||
@@ -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<void> {}
|
||||
async statsGet(): Promise<Stats> {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
|
||||
@@ -52,6 +52,18 @@ export async function vkInit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
// The terminal account-deleted screen. While app.accountDeleted is set, App.svelte renders
|
||||
// only this and all push/poll is stopped; the screen makes no network calls. Reopening the
|
||||
// app just creates a fresh account. The Close button closes the host Mini App (Telegram /
|
||||
// VK); a plain browser has nothing to close, so the button is hidden there.
|
||||
import { closeDeletedApp } from '../lib/app.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { insideVK } from '../lib/vk';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
const canClose = insideTelegram() || insideVK();
|
||||
</script>
|
||||
|
||||
<div class="deleted">
|
||||
<div class="card">
|
||||
<h1>{t('deleted.title')}</h1>
|
||||
<p class="msg">{t('deleted.body')}</p>
|
||||
{#if canClose}
|
||||
<button class="close" onclick={closeDeletedApp}>{t('common.close')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.deleted {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.card {
|
||||
max-width: 28rem;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.close {
|
||||
margin-top: 1.5rem;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||
import {
|
||||
app,
|
||||
applyLinkResult,
|
||||
confirmDeleteAccount,
|
||||
handleError,
|
||||
logout,
|
||||
requestDeleteAccount,
|
||||
showToast,
|
||||
} from '../lib/app.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
@@ -43,6 +51,10 @@
|
||||
let changingEmail = $state(false);
|
||||
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
|
||||
let confirmUnlink = $state<null | { kind: 'telegram' | 'vk'; label: string }>(null);
|
||||
// The account-deletion step-up dialog: null when closed, else the required proof method.
|
||||
let deleting = $state<null | { method: 'email' | 'phrase' }>(null);
|
||||
let deleteCode = $state('');
|
||||
let deletePhrase = $state('');
|
||||
const telegramLinkable = loginWidgetAvailable();
|
||||
|
||||
function defaultTz(): string {
|
||||
@@ -235,6 +247,29 @@
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// startDelete opens the deletion dialog, asking the backend which step-up the account uses
|
||||
// (a mailed code for an email account, or a typed phrase for a platform-only one).
|
||||
async function startDelete() {
|
||||
try {
|
||||
const method = await requestDeleteAccount();
|
||||
deleteCode = '';
|
||||
deletePhrase = '';
|
||||
deleting = { method };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
// On success the app swaps to the terminal "account deleted" screen (app.accountDeleted).
|
||||
await confirmDeleteAccount(deleteCode.trim(), deletePhrase.trim());
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -381,6 +416,10 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if !p.isGuest}
|
||||
<button class="deletebtn" onclick={startDelete} disabled={!connection.online}>{t('profile.deleteAccount')}</button>
|
||||
{/if}
|
||||
|
||||
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
|
||||
once its entry point is decided; logout() also still runs on an invalid session. -->
|
||||
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||
@@ -409,6 +448,28 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if deleting}
|
||||
{@const d = deleting}
|
||||
<Modal title={t('profile.deleteTitle')} onclose={() => (deleting = null)}>
|
||||
<p class="warn">{t('profile.deleteWarn')}</p>
|
||||
{#if d.method === 'email'}
|
||||
<p class="muted">{t('profile.deleteEmailPrompt')}</p>
|
||||
<div class="addrow">
|
||||
<input class="codein" bind:value={deleteCode} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t('profile.deletePhrasePrompt')}</p>
|
||||
<div class="addrow">
|
||||
<input bind:value={deletePhrase} placeholder={t('profile.deletePhrasePlaceholder')} autocapitalize="characters" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="addrow end">
|
||||
<button class="ghost" onclick={() => (deleting = null)}>{t('common.cancel')}</button>
|
||||
<button class="btn danger" onclick={doDelete} disabled={!connection.online}>{t('profile.deleteConfirm')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
@@ -583,6 +644,12 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
/* When a button row sits below its own field (the change-email and delete dialogs),
|
||||
separate them and keep the actions right-aligned. */
|
||||
.addrow.end {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.addrow input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -614,6 +681,18 @@
|
||||
.ghost:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.deletebtn {
|
||||
margin-top: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--danger, #c0392b);
|
||||
background: transparent;
|
||||
color: var(--danger, #c0392b);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.deletebtn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.logout {
|
||||
margin-top: 8px;
|
||||
align-self: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user