feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
# Spec: Multi-user profiles with PIN switching
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
|
||||
**Destination on completion:**
|
||||
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
|
||||
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
|
||||
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
|
||||
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
|
||||
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
|
||||
|
||||
## Summary
|
||||
|
||||
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
|
||||
same server** and switch between them in a couple of taps. Adult accounts can set
|
||||
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
|
||||
away. An adult who forgets their PIN signs in with their Jellyfin password
|
||||
instead — there is no separate reset flow.
|
||||
|
||||
The feature is **opt-in and invisible until used**: one account with no PIN
|
||||
behaves exactly as the app does today.
|
||||
|
||||
## Motivation
|
||||
|
||||
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
|
||||
`users`), keeps a token per user in the keyring, and exposes
|
||||
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
|
||||
only way to change account today is `auth_logout`, which calls Jellyfin's logout
|
||||
endpoint and **invalidates the token server-side**, forcing a full password login
|
||||
every time. On a living-room device shared by a family that is the difference
|
||||
between "switch to the kids' profile" and "find the password".
|
||||
|
||||
Two defects block simply exposing the existing commands, and both are the real
|
||||
work of this spec:
|
||||
|
||||
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
|
||||
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
|
||||
controls filter *server responses*, but the cache-first read path returns local
|
||||
rows before the server answers — so a child profile on a device a parent has
|
||||
browsed sees the parent's titles and artwork.
|
||||
2. **Download availability is answered per item, not per user.**
|
||||
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
|
||||
counts completed rows for an `item_id` with no user predicate, so a child's UI
|
||||
marks a parent's download as available and can play it offline.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
|
||||
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
|
||||
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
|
||||
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
|
||||
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
|
||||
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
|
||||
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
|
||||
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
|
||||
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
|
||||
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
|
||||
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
|
||||
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
|
||||
|
||||
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
|
||||
comes from the DB. Rust returns the timestamp; the frontend decides it means
|
||||
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
|
||||
|
||||
## Design
|
||||
|
||||
### Threat model — state it plainly
|
||||
|
||||
The PIN is a **switching gate against a member of the household**, not at-rest
|
||||
protection against an attacker with the disk. Tokens stay in the keyring exactly
|
||||
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
|
||||
does **not** encrypt them.
|
||||
|
||||
This is a deliberate choice, and the rejected alternative matters enough to
|
||||
record: wrapping each token with a key derived from its PIN would resist an
|
||||
offline attacker, but a locked profile would then be *unable to act as itself* —
|
||||
no resuming its downloads after a restart, no draining its `sync_queue`, no
|
||||
session polling — until someone walked past and typed four digits. On a device
|
||||
that reboots nightly that is a worse product for a threat this feature does not
|
||||
face. A four-digit code was never going to resist an offline attack anyway.
|
||||
|
||||
Consequences to document in 09-security.md rather than discover later:
|
||||
|
||||
- Anyone with the SQLite file and keyring access has every profile's token,
|
||||
PIN or not.
|
||||
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
|
||||
restriction is Jellyfin's server-side parental controls, which most self-hosters
|
||||
have never configured — the UI must say so when a PIN-less profile is created.
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
-- Migration 024
|
||||
CREATE TABLE user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
|
||||
failed_count INTEGER DEFAULT 0,
|
||||
locked_until TEXT, -- RFC3339; NULL when not locked out
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- What the server has actually shown to this user. NOT a maintained index:
|
||||
-- written as a byproduct of the cache write path, so it cannot disagree with
|
||||
-- what the server returned.
|
||||
CREATE TABLE user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
-- Same, at library granularity, from each user's /UserViews.
|
||||
CREATE TABLE user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
-- Downloads: one file, many claimants.
|
||||
CREATE TABLE download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Migration of existing installs is not optional.** Every current cache row and
|
||||
download predates the concept of a user. Migration 024 backfills
|
||||
`user_item_visibility` and `download_grants` for the single existing user (and,
|
||||
if somehow several `users` rows exist, for the one with `is_active = 1`).
|
||||
Without the backfill an upgrading user's library goes blank.
|
||||
|
||||
### Cache scoping — a byproduct, not an index
|
||||
|
||||
The reason this is tractable: the repository **already carries the user**.
|
||||
|
||||
```rust
|
||||
pub struct OfflineRepository {
|
||||
db_service: Arc<RusqliteService>,
|
||||
server_id: String,
|
||||
user_id: String, // already there, already used for user_data joins
|
||||
}
|
||||
```
|
||||
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
|
||||
|
||||
Every row enters the cache because *some specific user's* request returned it, and
|
||||
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
|
||||
— the single write choke point, called only from
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
|
||||
stamps `user_item_visibility` in the same transaction as the item. There is no
|
||||
reconciliation job and no way for the stamp to drift from what the server said,
|
||||
because the stamp *is* the record of what the server said.
|
||||
|
||||
Reads join through it. 43 of the 64 `FROM items` sites live in
|
||||
`repository/offline.rs`, where `self.user_id` is already in scope.
|
||||
|
||||
**The 21 sites outside the repository are triaged, not blanket-scoped:**
|
||||
|
||||
| Group | Disposition |
|
||||
|-------|-------------|
|
||||
| Browse/query paths returning lists to the UI | Must scope |
|
||||
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
|
||||
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
|
||||
|
||||
The triage result is recorded in 08-database-design.md, because "why isn't this
|
||||
one scoped?" is exactly what a future change gets wrong.
|
||||
|
||||
**Known limit — revocation drift.** The stamp can never show *more* than the
|
||||
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
|
||||
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
|
||||
visibility rows for libraries that disappeared. Offline, the cache stays
|
||||
stale-permissive. This is a stated property, not a bug.
|
||||
|
||||
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
|
||||
outside an allowlist of modules. With writes funnelled and 43 reads in one file
|
||||
the allowlist is short enough to mean something — unlike `check:boundary`, which
|
||||
had to pattern-match literals. It will not catch a missed join *inside* the
|
||||
repository; it will catch a new query appearing in a random command file, which
|
||||
is the realistic drift.
|
||||
|
||||
### Downloads — shared files, per-user grants
|
||||
|
||||
The file layout already assumes sharing: paths are content-derived
|
||||
(`{base}/{series}/{S01E02 - Name}`,
|
||||
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
|
||||
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
|
||||
episode already aim at one path and clobber each other. Formalising:
|
||||
|
||||
- A second profile requesting an already-downloaded item inserts a **grant**. No
|
||||
bytes transferred; immediately available.
|
||||
- `offline_is_available` joins through grants instead of counting rows per item.
|
||||
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
|
||||
drops the caller's grant and unlinks the file **only when the last grant goes**.
|
||||
It currently deletes unconditionally, which under sharing would yank a file from
|
||||
under another profile.
|
||||
- Budget is naturally shared: the file is counted once. Eviction picks files with
|
||||
no recent access across *any* grant.
|
||||
- A grant is not an entitlement. On unlock while online, grants for items the
|
||||
profile can no longer see are dropped, alongside the visibility re-derivation.
|
||||
|
||||
### Device ID
|
||||
|
||||
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
|
||||
installation, sent as `DeviceId` on every request
|
||||
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
|
||||
identify a *session* — the Dashboard → Devices row, and the target the
|
||||
remote-control feature casts to.
|
||||
|
||||
**Decision pending an empirical test** (see Open questions). Shipping default is a
|
||||
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
|
||||
member a distinct device entry so playback history attributes cleanly and the
|
||||
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
|
||||
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
|
||||
changes.
|
||||
|
||||
### Switch orchestration
|
||||
|
||||
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
|
||||
server-side; a switch must leave the outgoing profile able to come back with one
|
||||
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
|
||||
unit-testable without a player or a server:
|
||||
|
||||
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
|
||||
a straggler would report the outgoing profile's episode against the incoming one).
|
||||
2. Drain or park `sync_queue` for the outgoing user.
|
||||
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
|
||||
4. Destroy the repository handle.
|
||||
5. Flip `users.is_active`.
|
||||
6. Build the new repository, restart the poller, re-derive visibility and grants
|
||||
if online.
|
||||
7. Emit `profile-switched`.
|
||||
|
||||
Two hazards, both already documented in CLAUDE.md and both reached from a new
|
||||
direction here: never call blocking APIs from player event callbacks, and never
|
||||
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
|
||||
at once.
|
||||
|
||||
### Lock state vs. playback
|
||||
|
||||
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
|
||||
timeout, off by default) flips only the first:
|
||||
|
||||
- Audio keeps playing and keeps reporting as the profile that started it.
|
||||
- The lockscreen / MediaSession keeps full transport control over the **existing
|
||||
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
|
||||
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
|
||||
needs no changes at all.
|
||||
- The locked UI refuses anything reaching past the current queue: browsing,
|
||||
search, new playback, downloads, settings, switching profile without the PIN.
|
||||
|
||||
Two rules keep it coherent:
|
||||
|
||||
- **Never re-lock while something is playing.** The idle timer starts when
|
||||
playback stops, not when the UI goes quiet. This removes almost all of the
|
||||
conflict on its own.
|
||||
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
|
||||
profile leaves everything running.
|
||||
|
||||
The timer lives in Rust beside the player state machine: it needs authoritative
|
||||
playback state, and a frontend timer dies with the webview on Android.
|
||||
|
||||
### Startup
|
||||
|
||||
The picker appears only when the last-used profile has a PIN, **or** more than one
|
||||
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
|
||||
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
|
||||
does today. One account, no PIN → the user never sees any of this.
|
||||
|
||||
### Commands
|
||||
|
||||
Names match the Rust fns; top-level params auto-convert to camelCase.
|
||||
|
||||
```rust
|
||||
profiles_list() -> Vec<Profile>
|
||||
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
|
||||
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
|
||||
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
|
||||
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
|
||||
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
|
||||
profiles_remove(user_id: String, forget_downloads: bool)
|
||||
```
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod { None, Pin }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
Ok { user_id: String },
|
||||
WrongPin { attempts_remaining: u32 },
|
||||
LockedOut { until: String },
|
||||
NeedsPassword,
|
||||
}
|
||||
```
|
||||
|
||||
Event: `profile-switched` (kebab-case), payload `{ userId }`.
|
||||
|
||||
`profiles_add` takes no server URL — it authenticates against the *current*
|
||||
server. That is the same-server constraint, enforced in Rust rather than by
|
||||
omitting a form field.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Multiple servers. The schema already supports it (`users.server_id`); only the
|
||||
flow is constrained. Not a schema change to undo later.
|
||||
- Per-profile content restriction. That is Jellyfin's, server-side.
|
||||
- Profile avatars uploaded locally — use the server's `avatar_tag`.
|
||||
- Biometric unlock.
|
||||
- Idle re-lock is specified above but ships **off by default** and last.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
|
||||
- [ ] A second profile can be added with a password, against the current server only.
|
||||
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
|
||||
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
|
||||
- [ ] "Use password instead" signs in and offers to set a new PIN.
|
||||
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
|
||||
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
|
||||
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
|
||||
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
|
||||
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**
|
||||
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
|
||||
service restart; a cleared PIN removes the row.
|
||||
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
|
||||
teardown *ordering*, no player or server needed. This is the part that would
|
||||
otherwise only ever be hand-tested.
|
||||
- Visibility: `save_to_cache` stamps; a second user's read of the same item
|
||||
returns nothing; migration backfill populates the existing user.
|
||||
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
|
||||
cancel of the last grant unlinks it.
|
||||
- Same-server: `profiles_add` against a different URL is rejected.
|
||||
|
||||
**Frontend**
|
||||
- Picker renders from `profiles_list` with no unlock-method inference of its own.
|
||||
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
|
||||
never compares a PIN or counts an attempt locally.
|
||||
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
|
||||
|
||||
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
|
||||
differing library permissions on a real server; CI has neither. The state-machine
|
||||
extraction above is what keeps the risky half testable. The rest is a documented
|
||||
manual pass in the release checklist.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|-------|-----|
|
||||
| `profiles_*` commands | `UR-082 \| DR-267` |
|
||||
| PIN hash + lockout | `UR-083 \| DR-268` |
|
||||
| Password fallback | `UR-084 \| DR-269` |
|
||||
| Switch orchestration | `UR-082 \| DR-270` |
|
||||
| Visibility stamp/filter | `UR-082 \| DR-271` |
|
||||
| Download grants | `UR-082 \| IR-034, DR-272` |
|
||||
| Per-profile device ID | `UR-082 \| DR-273` |
|
||||
| Startup target | `UR-082 \| DR-274` |
|
||||
| Idle re-lock | `UR-083 \| DR-275` |
|
||||
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
|
||||
first user's token?** Two minutes with two accounts: log in as A, log in as B
|
||||
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
|
||||
the per-profile device ID is a preference or a requirement.
|
||||
2. Should removing a profile default to deleting its exclusive downloads, or
|
||||
keeping them? Spec currently makes it an explicit flag.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
|
||||
server-side invalidation is the whole reason it is unsuitable.
|
||||
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
|
||||
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
|
||||
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
|
||||
would strand every existing token.
|
||||
Reference in New Issue
Block a user