backoffice

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package backoffice is the back office the apps share: who may use an app (roles, access states), the session check that enforces it on every request, the admin routes over the accounts, View as, the announcement shown to everyone, the features an admin keeps to the admins, the audit log and the account stats. schema.sql lists the columns and the tables it needs beside lib's auth tables and audit_events.

Every app signs in with a 6-digit code sent by email: RequestCode and VerifyCode, drawn by the kit's SignIn.svelte. Another way to prove an address (a password, a provider) calls SignIn. Every handler that needs a signed-in user calls Check (or RequireAdmin); HandleMe and HandleSignOut serve /me and sign out. Mount serves the admin routes under /api/v1/admin/ and the features under /api/v1/features; the UI kit (lib's web/admin, web/features.svelte.ts) draws them.

Index

Constants

View Source
const (
	AccessActive     = "active"
	AccessRestrained = "restrained"
	AccessPending    = "pending"
)

Access states. The database keeps a pending account restrained with pending_since set: AccessPending only exists in the API.

View Source
const (
	AudienceEveryone = "everyone"
	AudienceAdmins   = "admins"
)

Audiences of a feature: everyone, or the admins only, as for a beta.

View Source
const (
	JoinedAdminEmail = "admin_email"
	JoinedFirstUser  = "first_user"
	JoinedOpen       = "open"
	JoinedUnknown    = "unknown_email"
)

Why a sign-in created an account, as the signed_up audit row and log line say it.

View Source
const (

	// CodeTTL is how long a sign-in code stays valid.
	CodeTTL = 15 * time.Minute
)

The sign-in by a 6-digit code sent by email. The codes live in lib's magic_links table (auth/schema 002 and the attempts column of 004) as an HMAC of the email and the code, under a key made at start and never stored: a copy of the database cannot give away an open code, and a restart voids them.

View Source
const RoleAdmin = "admin"

RoleAdmin is the role of the accounts that use the back office; every app has it.

Variables

View Source
var (
	// ErrRestrained refuses the sign-in of an account an admin restrained.
	ErrRestrained = errors.New("user_restrained")
)

Functions

func IsPending

func IsPending(accessState, pendingSince string) bool

IsPending reports whether an account signed up and waits for an admin, as opposed to one an admin restrained.

Types

type AdminUser

type AdminUser struct {
	User
	LastSeenAt         string `json:"last_seen_at"`
	ActiveSessionCount int64  `json:"active_session_count"`
	Connected          bool   `json:"connected"`
}

AdminUser is an account in the admin list. Connected means it has a live session of its own (not a View as).

type Announcement

type Announcement struct {
	Text      string `json:"text"`
	Tone      string `json:"tone"`
	UpdatedAt string `json:"updated_at"`
}

Announcement is the banner an admin shows to every active account.

type Backoffice

type Backoffice struct {
	// contains filtered or unexported fields
}

Backoffice serves the back office of one app.

func New

func New(cfg Config) (*Backoffice, error)

New checks the Config.

func (*Backoffice) AccountStats

func (b *Backoffice) AccountStats(ctx context.Context, now time.Time) (Stats, error)

AccountStats counts the accounts at now; last seen leaves out the sessions of a View as.

func (*Backoffice) ActiveAnnouncement

func (b *Backoffice) ActiveAnnouncement(ctx context.Context) (*Announcement, error)

ActiveAnnouncement returns the announcement shown now, or nil when there is none.

func (*Backoffice) Admit

func (b *Backoffice) Admit(ctx context.Context, tx *sql.Tx, email string, now time.Time) (User, string, error)

Admit returns the account of a sign-in that proved email, in the transaction that creates its session:

  • a new account is created: an admin when its email is a super admin's or the database has no account yet (or nobody could ever approve anyone), else the member role, active unless Config.Approval keeps it pending;
  • a super admin is made an admin again at every sign-in;
  • an account an admin restrained gets ErrRestrained.

joined says why the account was created, "" for an existing one. email must be normalized.

func (*Backoffice) AuditSignIn

func (b *Backoffice) AuditSignIn(ctx context.Context, tx *sql.Tx, u User, joined, via, ip string) error

AuditSignIn writes signed_up first when the sign-in created the account, then signed_in, in the sign-in transaction. via names the sign-in method.

func (*Backoffice) Check

func (b *Backoffice) Check(w http.ResponseWriter, r *http.Request) (Session, bool)

Check returns the session of an active account, or answers 401 (no valid session), 403 access_pending (an account waiting for an admin, who keeps the cookie) or 403 read_only_impersonation (a write while viewing the app as someone else) and returns false.

func (*Backoffice) CheckAllowPending

func (b *Backoffice) CheckAllowPending(w http.ResponseWriter, r *http.Request) (Session, bool)

CheckAllowPending is Check that also lets an account waiting for an admin through: for /me and sign out.

func (*Backoffice) EndSession

func (b *Backoffice) EndSession(w http.ResponseWriter, r *http.Request) (found bool, err error)

EndSession signs the request out and clears its cookie: a View as goes back to the admin's own session, any other session is revoked with a signed_out audit row. found is false when the request had no valid session.

func (*Backoffice) Features added in v0.5.0

func (b *Backoffice) Features(ctx context.Context) ([]FeatureState, error)

Features returns the features of Config.Features in their order, with their audience. An app that declares none reads nothing, so it needs no features table.

func (*Backoffice) HandleMe added in v0.8.0

func (b *Backoffice) HandleMe(w http.ResponseWriter, r *http.Request)

HandleMe is the handler of GET /api/v1/me: the Me of the session, a pending account's included; 401 without one.

func (*Backoffice) HandleSignOut added in v0.8.0

func (b *Backoffice) HandleSignOut(w http.ResponseWriter, r *http.Request)

HandleSignOut is the handler of POST /api/v1/sign-out: EndSession, then 204; 401 when there was no session.

func (*Backoffice) IsSuperAdmin

func (b *Backoffice) IsSuperAdmin(role, email string) bool

IsSuperAdmin reports whether an account is an admin whose email is in Config.SuperAdmins.

func (*Backoffice) ListUsers

func (b *Backoffice) ListUsers(ctx context.Context, connectedOnly bool) ([]AdminUser, error)

ListUsers returns every account, the pending ones first, then the newest; connectedOnly keeps the connected ones.

func (*Backoffice) MeOf

func (b *Backoffice) MeOf(ctx context.Context, sess Session) (Me, error)

MeOf returns /me for a session from CheckAllowPending.

func (*Backoffice) Mount

func (b *Backoffice) Mount(mux *http.ServeMux)

Mount serves the admin routes, each needing an admin session, and GET /api/v1/features, which needs none.

func (*Backoffice) RequestCode added in v0.8.0

func (b *Backoffice) RequestCode(w http.ResponseWriter, r *http.Request)

RequestCode is the handler of POST /api/v1/sign-in/code, body {"email"}: it emails a new sign-in code, which voids the previous one, and answers 204. A restrained account gets no code and the same 204. Five codes per address in 15 minutes, then 429 too_many_codes; when the mailer is a blackhole the code is logged as "login code (dev)".

func (*Backoffice) RequireAdmin

func (b *Backoffice) RequireAdmin(w http.ResponseWriter, r *http.Request) (Session, bool)

RequireAdmin is Check for the admins only: anyone else gets 403 forbidden.

func (*Backoffice) SignIn added in v0.8.0

func (b *Backoffice) SignIn(w http.ResponseWriter, r *http.Request, email, via string, also func(tx *sql.Tx, u User, joined string) error) (User, error)

SignIn signs the request in as email, an address the app has proven (by a password, a provider): in one transaction it admits the account (Admit), runs Config.OnSignIn then also (when not nil) with the account and why it was created ("" for an existing one), opens a session and writes the audit rows (AuditSignIn, with via), then sets the session cookie. A restrained account gets ErrRestrained and no cookie; an error of also or OnSignIn rolls the sign-in back and is returned as is.

func (*Backoffice) VerifyCode added in v0.8.0

func (b *Backoffice) VerifyCode(w http.ResponseWriter, r *http.Request)

VerifyCode is the handler of POST /api/v1/sign-in/verify, body {"email","code"}: with the open code of email it signs in (SignIn, via "code") and answers 204. A wrong code answers 400 invalid_or_expired_code; the 5th wrong one closes the code and answers too_many_attempts; a restrained account gets 403 user_restrained.

type Config

type Config struct {
	DB *sql.DB
	// Roles an admin can give, RoleAdmin included; MemberRole is the role of a new account that is not an admin.
	Roles      []string
	MemberRole string
	// SuperAdmins are the normalized emails of the app's admin flag: always admins, protected from the other
	// admins, the only ones who can view the app as someone else.
	SuperAdmins map[string]struct{}
	// Approval makes a new account wait for an admin unless Admit lets it in; without it every account is active.
	Approval bool
	// Admit, with Approval, lets a new account in at once and says why ("" keeps it pending); it runs in the
	// sign-in transaction.
	Admit func(ctx context.Context, tx *sql.Tx, email string) (string, error)
	// Actions are the app's own audit codes, listed after audit.AccountActions in the audit log filter.
	Actions []string
	// Sessions is the session lifetime policy the check refreshes sessions with.
	Sessions sessions.SessionPolicy
	// Features are the parts of the app an admin can keep to the admins, in the order the back office lists them.
	Features []Feature
	// OnSignIn runs in every sign-in transaction (a code, SignIn) right after Admit, before the session exists: the
	// app's own steps, such as dropping a password set before the address was proven. An error refuses the sign-in.
	OnSignIn func(ctx context.Context, tx *sql.Tx, u User, joined, via string) error
	// AppName names the app in the email that carries a sign-in code.
	AppName string
	// CodeEmail writes that email (subject, text, html) in the app's language; nil writes it in English.
	CodeEmail func(code string, ttl time.Duration) (subject, text, html string)
}

Config is what an app decides about its accounts.

type Feature added in v0.5.0

type Feature struct {
	Name  string `json:"name"`
	Label string `json:"label"`
}

Feature is a part of the app an admin can keep to the admins; the app's UI hides it from everyone else. Name is the key the UI tests, Label what the back office shows.

type FeatureState added in v0.5.0

type FeatureState struct {
	Feature
	Audience string `json:"audience"`
}

FeatureState is a feature with its audience: AudienceEveryone until an admin chooses.

type LoggedEvent

type LoggedEvent struct {
	audit.Event
	Actor       string `json:"actor"`
	TargetLabel string `json:"target_label"`
}

LoggedEvent is an audit event with its actor and target named for a person: emails for accounts.

type Me

type Me struct {
	UserID         string        `json:"user_id"`
	Email          string        `json:"email"`
	Role           string        `json:"role"`
	AccessState    string        `json:"access_state"`
	ImpersonatedBy string        `json:"impersonated_by,omitempty"`
	CanImpersonate bool          `json:"can_impersonate"`
	Announcement   *Announcement `json:"announcement,omitempty"`
}

Me is /me: the account, its API access state, who views the app as this user, whether the user may View as, and the announcement to show (only to an active account). An app with more to say embeds it in its own answer.

type Session

type Session struct {
	ID           string
	UserID       string
	Email        string
	Role         string
	AccessState  string // as stored: active or restrained
	PendingSince string
	// ImpersonatorID is the super admin viewing the app as this user, "" for a normal session.
	ImpersonatorID string
	CreatedAt      string
	ExpiresAt      string
	LastSeenAt     string
}

Session is the session of a request, with its account.

func (Session) Pending

func (s Session) Pending() bool

Pending reports whether the account waits for an admin.

type Stats

type Stats struct {
	Total      int            `json:"total"`
	ByRole     map[string]int `json:"by_role"`
	Active24h  int            `json:"active_24h"`
	Active7d   int            `json:"active_7d"`
	NeverBack  int            `json:"never_seen_since_sign_up"`
	New7d      int            `json:"new_7d"`
	Pending    int            `json:"pending"`
	Restrained int            `json:"restrained"`
}

Stats counts the accounts: by role and access, new and active over the last day and week.

type User

type User struct {
	ID           string `json:"id"`
	Email        string `json:"email"`
	Role         string `json:"role"`
	AccessState  string `json:"access_state"` // the API value: active, restrained or pending
	PendingSince string `json:"-"`
	CreatedAt    string `json:"created_at"`
}

User is an account as the back office sees it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL