Add token rotation

jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.

Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 13:58:50 +00:00
1 parent 6b44b92975
commit 0b2159b1a4
21 files changed
+755 -46

No files matched your search

+14
View File
@@ -191,6 +191,20 @@ type TokenInput struct {
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// TokenRotation gives a token a new secret (POST /tokens/{id}/rotate).
// Every field is optional.
type TokenRotation struct {
// Grace keeps the old secret working this long (e.g. "1h", "2d"; at
// most 7d), so clients can be updated without an outage. Default:
// the old secret stops at once.
Grace string `json:"grace,omitempty"`
// The new expiry. Default: a token that expires gets its lifetime
// again, counted from now; one that doesn't, still doesn't.
ExpiresIn string `json:"expires_in,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
NoExpiry bool `json:"no_expiry,omitempty"`
}
// ZoneImport imports zone file text into a zone.
type ZoneImport struct {
Text string `json:"text"`
+7
View File
@@ -105,6 +105,13 @@ func NewToken() (token, publicID, secretHash string) {
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// RotateToken makes a new token with an existing public ID (a rotated
// token keeps its ID; only the secret changes).
func RotateToken(publicID string) (token, secretHash string) {
secret := randomString(20)
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
+14
View File
@@ -89,6 +89,20 @@ type Token struct {
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// RotatedAt is when the token last got a new secret (nil: never).
RotatedAt *time.Time `json:"rotated_at,omitempty"`
// After a rotation with a grace period, the previous secret keeps
// working until PrevExpiresAt.
PrevSecretHash string `json:"-"`
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
}
// IssuedAt is when the token's current secret was made.
func (t *Token) IssuedAt() time.Time {
if t.RotatedAt != nil {
return *t.RotatedAt
}
return t.CreatedAt
}
// Session is a logged-in web session.
+41 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net/http"
"strings"
"sync"
@@ -78,6 +79,31 @@ func (l *rateLimiter) cleanup() {
}
}
// throttle allows an event once a minute per key (for log lines that
// could otherwise repeat on every request).
type throttle struct {
mu sync.Mutex
last map[string]time.Time
}
func (t *throttle) allow(key string, now time.Time) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.last == nil {
t.last = map[string]time.Time{}
}
if now.Sub(t.last[key]) < time.Minute {
return false
}
for k, v := range t.last {
if now.Sub(v) > time.Hour {
delete(t.last, k)
}
}
t.last[key] = now
return true
}
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"}
// authenticate identifies the caller from a bearer token or a session
@@ -143,10 +169,19 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if err != nil {
return nil, err
}
if !auth.CheckSecret(secret, t.SecretHash) {
return nil, errors.New("wrong token secret")
}
now := time.Now()
previous := false
if !auth.CheckSecret(secret, t.SecretHash) {
// After a rotation with a grace period the old secret works until
// the grace period ends.
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
return nil, errors.New("wrong token secret")
}
if now.After(*t.PrevExpiresAt) {
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
}
previous = true
}
if t.RevokedAt != nil {
return nil, errors.New("token was revoked")
}
@@ -163,6 +198,9 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
+123 -14
View File
@@ -1,7 +1,10 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -106,25 +109,28 @@ func parseExpiry(in api.TokenInput) (*time.Time, error) {
if in.ExpiresIn == "" {
return nil, nil
}
v := strings.TrimSpace(in.ExpiresIn)
var d time.Duration
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days <= 0 {
return nil, badRequest("invalid expires_in %q", v)
}
d = time.Duration(days) * 24 * time.Hour
} else {
var err error
d, err = time.ParseDuration(v)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v)
}
d, err := parseDays(in.ExpiresIn)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
t := time.Now().Add(d)
return &t, nil
}
// parseDays parses a duration in Go syntax ("720h", "15m") or in days
// ("90d").
func parseDays(v string) (time.Duration, error) {
v = strings.TrimSpace(v)
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 0 {
return 0, fmt.Errorf("invalid duration %q", v)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(v)
}
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
var in api.TokenInput
if err := decodeJSON(r, &in); err != nil {
@@ -243,6 +249,109 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The
// token keeps its ID, name, user and grants, so nothing needs to be set up
// again; only the clients that use it need the new secret. The old secret
// stops working at once, or after the grace period. A token that expires
// gets the same lifetime again, counted from now, unless expires_in or
// expires_at says otherwise.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
body, err := readBody(r)
if err != nil {
return err
}
if body != nil {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return badRequest("invalid JSON: %v", err)
}
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return notFound("token %d not found", id)
}
return err
}
if t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
// A restricted token may rotate itself, and tokens no wider than
// itself, but not a wider token of the same user: it would learn that
// token's new secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "" || in.ExpiresAt != nil:
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in or expires_at")
}
if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil {
return err
}
case in.NoExpiry:
case t.ExpiresAt != nil:
life := t.ExpiresAt.Sub(t.IssuedAt())
if life <= 0 {
return badRequest("can't tell the token's lifetime; give expires_in")
}
e := now.Add(life)
exp = &e
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := parseDays(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is revoked; create a new one", id)
}
return err
}
t, err = s.store.GetToken(r.Context(), id)
if err != nil {
return err
}
detail := "old secret stops working now"
if prevUntil != nil {
detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
if t.ExpiresAt != nil {
detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
+1
View File
@@ -26,6 +26,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
// Users (admin)
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
+2
View File
@@ -53,6 +53,8 @@ type Server struct {
web fs.FS
certs *certReloader
statsQ *stats.Querier
// oldSecretWarn limits warnings about rotated tokens' old secrets.
oldSecretWarn throttle
mu sync.Mutex
srvs []*http.Server
+98
View File
@@ -0,0 +1,98 @@
package server
import (
"context"
"strings"
"testing"
"time"
)
func TestTokenRotation(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/users", e.token, map[string]any{"username": "ci", "password": "", "grant_strings": []string{"records:write zones=example.com"}}, 201)
var ciID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "ci" {
ciID = int64(u["id"].(float64))
}
}
created := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "deploy", "grant_strings": []string{"records:write zones=example.com types=TXT"}, "expires_in": "30d"}, 201)
id := itoa(int64(created["id"].(float64)))
old := created["token"].(string)
works := func(tok string) bool {
_, status := e.req("GET", "/auth/whoami", tok, nil)
return status == 200
}
// Rotation keeps the token and its grants; the old secret stops.
rot := e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
tok1 := rot["token"].(string)
if tok1 == old || rot["id"] != created["id"] || rot["name"] != "deploy" || rot["public_id"] != created["public_id"] || rot["rotated_at"] == nil {
t.Errorf("rotated = %v", rot)
}
if g := rot["grant_strings"].([]any); len(g) != 1 || g[0] != "records:write zones=example.com types=TXT" {
t.Errorf("grants after rotation = %v", g)
}
if !works(tok1) || works(old) {
t.Errorf("after rotation: new works %v, old works %v", works(tok1), works(old))
}
// The 30-day lifetime starts again.
exp, _ := time.Parse(time.RFC3339Nano, rot["expires_at"].(string))
if d := time.Until(exp); d < 29*24*time.Hour || d > 31*24*time.Hour {
t.Errorf("expires in %v, want about 30 days", d)
}
// With a grace period both secrets work for a while.
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
tok2 := rot["token"].(string)
if !works(tok2) || !works(tok1) || rot["previous_secret_valid_until"] == nil {
t.Errorf("grace: new %v, old %v, %v", works(tok2), works(tok1), rot)
}
// When the grace period ends, the old secret stops.
e.st.DB().ExecContext(context.Background(), `UPDATE tokens SET prev_expires_at = ? WHERE id = ?`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), id)
if b, status := e.req("GET", "/auth/whoami", tok1, nil); status != 401 || !strings.Contains(string(b), "grace period has ended") {
t.Errorf("old secret after grace: %d %s", status, b)
}
// Rotating again without grace drops the previous secret at once.
e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
tok3 := rot["token"].(string)
if works(tok2) || !works(tok3) || rot["previous_secret_valid_until"] != nil {
t.Errorf("rotation without grace kept an old secret: %v", rot)
}
// A restricted token may rotate itself...
self := e.do("POST", "/tokens/"+id+"/rotate", tok3, map[string]any{"expires_in": "2d"}, 200)
tok4 := self["token"].(string)
if works(tok3) || !works(tok4) {
t.Error("self-rotation didn't replace the secret")
}
// ...but not a wider token of its user, whose secret it would learn.
wide := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "wide"}, 201)
wideID := itoa(int64(wide["id"].(float64)))
if b, status := e.req("POST", "/tokens/"+wideID+"/rotate", tok4, nil); status != 403 {
t.Errorf("restricted token rotated a wider one: %d %s", status, b)
}
// The unrestricted token can rotate the narrower one.
e.do("POST", "/tokens/"+id+"/rotate", wide["token"].(string), map[string]any{"no_expiry": true}, 200)
// Nobody else's tokens.
if _, status := e.req("POST", "/tokens/1/rotate", wide["token"].(string), nil); status != 404 {
t.Errorf("rotated the admin's token: %d", status)
}
for _, c := range []struct {
body map[string]any
code int
}{
{map[string]any{"grace": "8d"}, 400},
{map[string]any{"grace": "soon"}, 400},
{map[string]any{"expires_in": "1d", "no_expiry": true}, 400},
} {
if b, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, c.body); status != c.code {
t.Errorf("%v: %d %s", c.body, status, b)
}
}
e.do("DELETE", "/tokens/"+id, e.token, nil, 204)
if _, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, nil); status != 409 {
t.Errorf("rotated a revoked token: %d", status)
}
}
+7
View File
@@ -213,6 +213,13 @@ CREATE TABLE deployed_files (
written_at TEXT NOT NULL,
changeset_id INTEGER NOT NULL DEFAULT 0
);
`,
// 2: token rotation. A rotated token gets a new secret; the previous
// one may stay valid for a grace period.
`
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
}
+23 -3
View File
@@ -139,13 +139,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev sql.NullString
var grants, exp, lu, rev, rot, pexp sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
return nil, err
}
if grants.Valid {
@@ -157,6 +157,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
t.CreatedAt = parseTS(ca)
return t, nil
}
@@ -235,6 +236,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error {
return nil
}
// RotateToken gives a token a new secret and expiry. With prevUntil set,
// the old secret keeps working until then; otherwise it stops at once. A
// revoked token can't be rotated (ErrNotFound).
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
prevHash := `''`
if prevUntil != nil {
prevHash = `secret_hash`
}
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use. It is best-effort and cheap.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
+57 -8
View File
@@ -1,4 +1,4 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js";
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js";
import { api, get, state } from "../api.js";
import { rerender } from "../app.js";
@@ -101,20 +101,69 @@ export async function tokensDialog(u, grants, own) {
export function tokenTable(tokens, redraw) {
return table([
{ label: "Name", render: (t) => h("strong", null, t.name) },
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
{ label: "", render: (t) => h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
await api("DELETE", "/tokens/" + t.id);
toast("Token revoked");
redraw();
} }, "Revoke"), tdClass: "actions-cell" },
{ label: "", render: (t) => [
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
await api("DELETE", "/tokens/" + t.id);
toast("Token revoked");
redraw();
} }, "Revoke")], tdClass: "actions-cell nowrap" },
], tokens, { empty: "No tokens." });
}
// rotationNote says when a token last got a new secret, and whether its
// old secret still works.
function rotationNote(t) {
const parts = [];
if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) {
parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until)));
} else if (t.rotated_at) {
parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at)));
}
return parts;
}
// rotateDialog gives a token a new secret. The token keeps its name,
// grants and ID; only the programs using it need the new secret.
async function rotateDialog(t, redraw) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const form = h("div", null,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
e.target.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.token),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works.")));
if (redraw) redraw();
} catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate")));
mount(box, form);
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export function newTokenForm(base, grants, redraw) {
const err = h("div");
const out = h("div");
+1
View File
@@ -78,6 +78,7 @@ h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.warn-text { color: var(--warn); }
.right { text-align: right; }
.hidden { display: none !important; }
+8 -5
View File
@@ -49,12 +49,15 @@ export function fmtTime(iso) {
export function relTime(iso) {
if (!iso) return "";
const s = (Date.now() - new Date(iso).getTime()) / 1000;
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
const s = Math.abs(diff);
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago";
return fmtTime(iso);
let n;
if (s < 3600) n = Math.floor(s / 60) + " min";
else if (s < 86400) n = Math.floor(s / 3600) + " h";
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
else return fmtTime(iso);
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
}
export function time(iso) {