diff --git a/Cargo.lock b/Cargo.lock index a0069b3..6bf3bdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4ff32df..3e14ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 352f462..9bd1b46 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 7735ca7..bd3ba0c 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -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; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 777e1b5..5c2595e 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -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, diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 5ae8a34..1ba69a7 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -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='' ``` -## 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 diff --git a/scripts/build-and-publish-unraid.sh b/scripts/build-and-publish-unraid.sh index 4e9f28c..e879ae4 100644 --- a/scripts/build-and-publish-unraid.sh +++ b/scripts/build-and-publish-unraid.sh @@ -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"