Compare commits

1 Commits
Author SHA1 Message Date
Miles WardandClaude Fable 5.1 3c83cd9928 v0.1.1: monochrome UI, spaced settings tabs, keychain-safe publishing
The accent is ink rather than blue: primary buttons, the floating Add
button, the empty-state shield, the active settings tab, the switch, the
countdown ring and the initials tile. Light mode uses a deep near-black
with a whisper of cool hue, dark mode a near-white — the Daito / Beautiful
UI treatment. Status colours and the platform dots keep their hue because
they carry meaning. The input focus glow stays, now a neutral 4px halo
with a mid-grey edge.

The per-issuer hue helper in the web UI is gone; the rendered chat tile
still uses its issuer colour, where it helps tell accounts apart.

Settings tabs get an 8px gap: each pill carries a 1px hairline ring
outside its box, so 4px read as the pills touching.

build-and-publish-unraid.sh writes registry auth into its private Docker
config instead of `docker login`/`docker logout`. With an empty config
the CLI falls back to docker-credential-osxkeychain, which shares its
keychain item with git's helper: login fails with -25299 and logout
deletes the credential git uses. PUBLISHING.md documents the trap and the
release flow for this version.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-09 00:47:37 -04:00
7 changed files with 146 additions and 86 deletions

No files matched your search

Generated
+6 -6
View File
@@ -1210,7 +1210,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openmfa"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
@@ -1225,7 +1225,7 @@ dependencies = [
[[package]]
name = "openmfa-core"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"argon2",
"base32",
@@ -1250,7 +1250,7 @@ dependencies = [
[[package]]
name = "openmfa-delivery"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"axum",
"openmfa-core",
@@ -1268,7 +1268,7 @@ dependencies = [
[[package]]
name = "openmfa-http-api"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"axum",
"base64 0.22.1",
@@ -1290,7 +1290,7 @@ dependencies = [
[[package]]
name = "openmfa-render"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"image",
"resvg",
@@ -1302,7 +1302,7 @@ dependencies = [
[[package]]
name = "openmfa-webui"
version = "0.1.0"
version = "0.1.1"
[[package]]
name = "parking_lot"
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
+13 -1
View File
@@ -314,7 +314,9 @@ and the binary is genuinely standalone.
The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
JetBrains Mono. Light and dark both ship.
JetBrains Mono. The accent is monochrome in the vein of [Daito](https://daito.io):
ink in light mode, near-white in dark, so the only colour on screen is status
(live, expiring, error) and the three platform dots. Light and dark both ship.
### Testing
@@ -341,6 +343,16 @@ renames refresh without waiting for a rollover.
have it on screen.
- Email → chat forwarding for services that send codes by email.
## Changelog
- **v0.1.1** — Monochrome UI. The accent is ink rather than blue across the
primary buttons, the floating **Add an account** button, the empty-state
shield, the settings tabs and the focus glow on inputs; the settings tabs
are spaced so their hairline rings no longer touch.
- **v0.1** — First release: live TOTP tiles to Discord, Slack or Telegram, a
three-way provider picker at setup, Google Authenticator export import,
QR upload / camera / paste, and the Unraid template.
## Licence
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
+41 -24
View File
@@ -34,10 +34,19 @@
--stripe: oklch(40.5% 0 0/.075);
--stripe-bg: oklch(97% 0 0);
--accent: oklch(62.6% .205 254.947);
--accent-ink: oklch(55.6% .187 255.617);
--accent-tint: oklch(96% .019 252.878);
/* Monochrome accent. The primary colour is ink, not blue: a deep
near-black with a whisper of cool hue in light mode (the Daito /
Beautiful UI treatment), inverted to near-white in dark mode. Status
colours below stay saturated because they carry meaning. */
--accent: oklch(21% .012 262);
--accent-ink: oklch(31% .012 262);
--accent-tint: oklch(0% 0 0 / .07);
--accent-edge: oklch(10% .012 262);
--on-accent: oklch(100% 0 0);
--focus-edge: oklch(50.6% .01 264.477);
--glow: oklch(0% 0 0 / .1);
--selection: oklch(0% 0 0 / .14);
--shadow-fab: 0 6px 20px oklch(0% 0 0 / .18), 0 2px 6px oklch(0% 0 0 / .12);
--green: oklch(60.3% .155 150.883);
--green-tint: oklch(60.3% .155 150.883/.12);
@@ -55,7 +64,7 @@
--shadow-raised: 0 0 0 1px oklch(91.2% .005 258.326), 0 2px 10px #0000000f;
--shadow-overlay: 0 0 0 1px oklch(91.2% .005 258.326), 0 16px 48px #00000021;
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.12);
--ring: 0 0 0 3px var(--accent-tint);
--ring: 0 0 0 4px var(--glow);
--radius: 8px;
--radius-lg: 12px;
@@ -92,10 +101,15 @@
--stripe: oklch(100% 0 0/.055);
--stripe-bg: oklch(22.6% .004 264.485);
--accent: oklch(68% .173 253.301);
--accent-ink: oklch(78.8% .113 248.33);
--accent-tint: oklch(68% .173 253.301/.16);
--on-accent: oklch(20% .01 253);
--accent: oklch(96.4% .002 247.839);
--accent-ink: oklch(89% .003 248);
--accent-tint: oklch(100% 0 0 / .1);
--accent-edge: oklch(100% 0 0 / .22);
--on-accent: oklch(20.9% .004 264.477);
--focus-edge: oklch(73.1% .008 260.731);
--glow: oklch(100% 0 0 / .15);
--selection: oklch(100% 0 0 / .2);
--shadow-fab: 0 6px 20px oklch(0% 0 0 / .5), 0 2px 6px oklch(0% 0 0 / .35);
--green: oklch(70.5% .154 153.814);
--green-tint: oklch(70.5% .154 153.814/.14);
@@ -134,10 +148,11 @@ h2 { font-size: 17px; }
h3 { font-size: 14px; }
p { margin: 0; }
a { color: var(--accent-ink); text-decoration: none; }
a:hover { text-decoration: underline; }
a { color: var(--ink); text-decoration: underline;
text-decoration-color: var(--ink-3); text-underline-offset: 2px; }
a:hover { text-decoration-color: var(--ink); }
::selection { background: var(--accent-tint); }
::selection { background: var(--selection); }
:focus-visible {
outline: none;
@@ -238,7 +253,7 @@ a:hover { text-decoration: underline; }
.search input::placeholder { color: var(--ink-3); }
.search input:focus {
outline: none;
border-color: var(--accent);
border-color: var(--focus-edge);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.search .clear {
@@ -273,8 +288,7 @@ a:hover { text-decoration: underline; }
.btn-primary {
background: var(--accent); color: var(--on-accent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 1px 2px #00000026;
box-shadow: 0 0 0 1px var(--accent-edge), 0 1px 2px oklch(0% 0 0 / .15);
}
.btn-primary:hover { background: var(--accent-ink); }
@@ -347,7 +361,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
line-height: 1.6; }
.input::placeholder { color: var(--ink-3); }
.input:focus, .select:focus, textarea.input:focus {
outline: none; border-color: var(--accent);
outline: none; border-color: var(--focus-edge);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.input[aria-invalid="true"] { border-color: var(--red); }
@@ -404,14 +418,17 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
}
.acct:hover { background: var(--hover); }
.acct .avatar {
/* Initials tile. Shared by the account list and the import preview. Neutral
rather than a per-issuer hue so the list stays monochrome; the rendered
chat tile keeps its issuer colour, where it helps tell accounts apart. */
.avatar {
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 10px;
font-size: 15px; font-weight: 600;
color: var(--on-accent);
background: var(--accent);
box-shadow: inset 0 0 0 1px #0000001a;
color: var(--ink);
background: var(--hover-2);
box-shadow: inset 0 0 0 1px var(--line-strong);
user-select: none;
}
@@ -517,9 +534,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
color: var(--on-accent);
background: var(--accent);
border: 0; border-radius: 999px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 6px 20px color-mix(in oklab, var(--accent) 40%, transparent),
0 2px 6px #0000001f;
box-shadow: 0 0 0 1px var(--accent-edge), var(--shadow-fab);
cursor: pointer;
transition: transform .16s var(--ease), background .16s var(--ease);
}
@@ -606,7 +621,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
transition: border-color .16s var(--ease), background .16s var(--ease);
}
.dropzone:hover, .dropzone.over {
border-color: var(--accent);
border-color: var(--focus-edge);
background: var(--accent-tint);
}
.dropzone svg { width: 26px; height: 26px; color: var(--ink-3); margin-bottom: 8px; }
@@ -784,7 +799,9 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
/* ── Settings ─────────────────────────────────────────────────────────── */
.settings-nav { display: flex; gap: 4px; margin-bottom: 18px; flex-wrap: wrap; }
/* 8px, not 4: each pill carries a 1px hairline ring outside its box, so a
smaller gap reads as the pills touching. */
.settings-nav { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.kv { display: flex; justify-content: space-between; gap: 16px;
padding: 9px 0; font-size: 13px; }
-11
View File
@@ -162,15 +162,6 @@ function initials(str) {
return (s[0] || '?').toUpperCase();
}
/** Deterministic hue per issuer so each account keeps a stable avatar
* colour across reloads without storing one. */
function avatarStyle(seed) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
const hue = Math.abs(hash) % 360;
return `background: oklch(62% 0.17 ${hue}); color: oklch(100% 0 0);`;
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
@@ -599,7 +590,6 @@ function acctRow(a) {
ring.dataset.ring = a.id;
const avatar = h('div.avatar', { text: initials(a.issuer || a.label) });
avatar.setAttribute('style', avatarStyle(a.issuer || a.label || a.id));
const pushDot = h('span.push-dot', {
title: pushTitle(a),
@@ -1110,7 +1100,6 @@ function openAddSheet() {
const list = h('div.import-list', {}, rows.map((r, i) => {
const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) });
av.setAttribute('style', avatarStyle(r.acct.issuer || r.acct.label || String(i)));
return h('label.import-row', {}, [
boxes[i],
av,
+63 -31
View File
@@ -1,73 +1,105 @@
# Publishing to Gitea
Everything here needs a Gitea **personal access token**, which is why it isn't
automated: the token should only ever exist in your own shell.
Releases are cut by hand. Each one needs a Gitea **personal access token**
with the `write:repository` and `write:package` scopes, which is why none of
this is automated: the token should only ever exist in your own shell or
keychain.
Create one at **Gitea → Settings → Applications → Generate New Token** with
scopes:
- `write:repository` — to create the repo and push
- `write:package` — to push the container image
Export it once per shell session:
On a Mac that has pushed before, git already holds the token in the login
keychain and nothing needs exporting. Elsewhere, create one at **Gitea →
Settings → Applications → Generate New Token** and export it once per shell
session:
```bash
export GITEA_TOKEN='<paste-your-token>'
```
## 1. Create the repository
## 1. Cut the version
Bump `version` in the workspace `Cargo.toml`, update the `IMAGE_TAG` and
`GIT_REF` defaults in `scripts/build-and-publish-unraid.sh`, add a line to the
README changelog, and commit. Then tag:
```bash
curl -s -X POST "https://gitea.milesward.dev/api/v1/user/repos" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d '{"name":"OpenMFA","description":"Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram. Container-native Rust.","private":false,"auto_init":false}'
git tag -a v0.1.1 -m "OpenMFA v0.1.1"
```
The repo is public so Unraid can pull the image anonymously without registry
credentials; the Gitea instance itself is not exposed beyond the LAN.
Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.1.1`, … — and the image carries
the same number without the `v` (`0.1`, `0.1.1`) plus a moving `latest`.
## 2. Push the code and the tag
```bash
cd "/Users/casperadmin/OpenMFA" && git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" push -u origin main --follow-tags
git push origin main --follow-tags
```
That pushes `main` and the `v0.1` tag together.
With a token in the environment rather than the keychain, prefix the command
with `git -c "http.extraHeader=Authorization: token $GITEA_TOKEN"`.
## 3. Push the container image
## 3. Build the image
The image is already built and tagged locally as
`gitea.milesward.dev/mward4/openmfa:0.1` and `:latest`.
The Dockerfile cross-compiles to `x86_64-unknown-linux-musl` from whatever
architecture the builder runs on, so this is fast on Apple Silicon — no QEMU.
`--provenance=false --sbom=false` matters: Unraid's Docker cannot load the
attestation manifests BuildKit attaches by default.
```bash
printf '%s' "$GITEA_TOKEN" | docker login gitea.milesward.dev --username mward4 --password-stdin && docker push gitea.milesward.dev/mward4/openmfa:0.1 && docker push gitea.milesward.dev/mward4/openmfa:latest && docker logout gitea.milesward.dev
docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.1.1 -t gitea.milesward.dev/mward4/openmfa:latest .
```
If the image isn't built (a fresh machine, say), `scripts/build-and-publish-unraid.sh`
does the clone, build, login and push in one pass:
## 4. Push the image
**Never run `docker login` or `docker logout` for `gitea.milesward.dev` on a
Mac where git keeps the token in the keychain.** Docker's `osxkeychain`
credential helper maps to the same keychain item as git's: `login` fails with
"The specified item already exists in the keychain (-25299)", and `logout`
silently deletes the credential git was using, so the next `git push` fails
with a bare 401. Write the auth into a throwaway config instead — a config
that already contains an `auths` entry makes the CLI skip the helper:
```bash
export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.1.1 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG
```
On a Colima host, `export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"`
first: an isolated `DOCKER_CONFIG` has no `currentContext`, and the CLI would
otherwise look for `/var/run/docker.sock`.
On a fresh machine, `scripts/build-and-publish-unraid.sh` does the clone,
build and push in one pass using that same private-config approach:
```bash
GITEA_TOKEN="$GITEA_TOKEN" ./scripts/build-and-publish-unraid.sh
```
## 4. Deploy on Unraid
## 5. Verify the pull
The repo is public, so this needs no credentials — which is exactly how Unraid
will fetch it:
```bash
docker pull gitea.milesward.dev/mward4/openmfa:0.1.1
```
## 6. Deploy on Unraid
Add a container with Repository set to:
```
gitea.milesward.dev/mward4/openmfa:0.1
gitea.milesward.dev/mward4/openmfa:0.1.1
```
Since the package is in a private registry, add the Gitea credentials under
Unraid's Docker → Registries first, or the pull will 401.
Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`,
publish port `8720`, and leave networking on bridge.
Or import `deploy/unraid/openmfa.xml` into
(or `:latest`). Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`,
publish port `8720`, and leave networking on bridge. Or import
`deploy/unraid/openmfa.xml` into
`/boot/config/plugins/dockerMan/templates-user/` to get all of that prefilled.
To upgrade an existing container, change the tag and apply. The vault, key,
settings and admin account all live in the appdata mount and carry over.
## Afterwards
Confirm the token isn't left lying around in your shell history:
If a token was exported, make sure it is not left in the shell:
```bash
unset GITEA_TOKEN
+22 -12
View File
@@ -13,9 +13,9 @@
# GITEA_OWNER default: mward4
# GITEA_REPO default: OpenMFA
# IMAGE_NAME default: openmfa
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
# IMAGE_TAG default: 0.1.1 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1 (tag/branch to build)
# GIT_REF default: v0.1.1 (tag/branch to build)
# WORKDIR default: /tmp/openmfa-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
@@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-OpenMFA}
IMAGE_NAME=${IMAGE_NAME:-openmfa}
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
IMAGE_TAG=${IMAGE_TAG:-0.1.1}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1}
GIT_REF=${GIT_REF:-v0.1.1}
WORKDIR=${WORKDIR:-/tmp/openmfa-build}
# OCI distribution rejects uppercase repository paths, so the image name is
@@ -51,10 +51,23 @@ done
# A private docker config so the token never lands in ~/.docker/config.json.
# The trap wipes it (and the checkout) on any exit path.
#
# The auth is written into the file directly rather than via `docker login`.
# With an empty config the CLI falls back to the platform credential helper,
# and on macOS `docker-credential-osxkeychain` shares its keychain item with
# git's `osxkeychain` helper: `login` then fails with -25299 if git already
# stored the token, and `logout` deletes the credential git was using. A
# config that already contains an `auths` entry skips the helper entirely.
DOCKER_CONFIG="$(mktemp -d)"
export DOCKER_CONFIG
trap 'rm -rf "$DOCKER_CONFIG" "$WORKDIR"' EXIT
# Colima keeps its socket outside /var/run; an isolated DOCKER_CONFIG has no
# `currentContext`, so point at the daemon explicitly when one is not set.
if [[ -z "${DOCKER_HOST:-}" && -S "$HOME/.colima/default/docker.sock" ]]; then
export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
fi
echo
echo "==> Workspace : $WORKDIR"
echo "==> Gitea host : $GITEA_HOST"
@@ -84,18 +97,15 @@ docker build \
--tag "$LATEST_TAG" \
.
# Step 3: log in. --password-stdin keeps the token out of `ps` and shell history.
printf '%s' "$GITEA_TOKEN" | docker login \
--username "$GITEA_OWNER" \
--password-stdin \
"$GITEA_HOST"
# Step 3: registry auth, written straight into the private config (see above).
printf '{"auths":{"%s":{"auth":"%s"}}}' "$GITEA_HOST" \
"$(printf '%s:%s' "$GITEA_OWNER" "$GITEA_TOKEN" | base64 | tr -d '\n')" \
> "$DOCKER_CONFIG/config.json"
# Step 4: push both tags.
# Step 4: push both tags. The trap removes the config, so there is no logout.
docker push "$FULL_TAG"
docker push "$LATEST_TAG"
docker logout "$GITEA_HOST" || true
echo
echo "==> Done. Pull from an Unraid template with:"
echo " $FULL_TAG"