diff --git a/README.md b/README.md index 3e09f56..3550199 100644 --- a/README.md +++ b/README.md @@ -226,18 +226,30 @@ What is not here: transferring a directory, dragging between the panes, and rout bastion — the last needs jump hosts the connection layer has not got. All three are in [`docs/design-import-gaps.md`](docs/design-import-gaps.md). -### Working as a team +### Sharing a vault -**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and -archives one. One distinction runs through the whole screen and is worth having before you use it. +**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one, +shares its key, renames it and hands it over. One distinction runs through the whole screen and is worth +having before you use it. -**Adding somebody to a team and giving them a key are two different acts, and only the first is something -the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in -their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's -public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has -the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed -over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so -rather than implying the server handed anything out. +**A vault is the thing you make, and the group of people is behind it.** The server authorises through a +*team* — `VaultAccessService` resolves a shared vault through `team_membership`, and every membership call +names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries +it, named after the vault and owned by you. So the thing you came to share is the thing you create, and +"which team is this in" stops being a question you need an answer to before you can share four servers with +two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else. + +The one case where the distinction resurfaces is a team owning several vaults, which this screen cannot +produce and does not hide: the members section then says so, because adding somebody to one of those vaults +adds them to all of them. + +**Adding somebody to a vault and giving them its key are two different acts, and only the first is something +the server can do.** Adding a member changes what the server will *serve* them: the vault appears in their +list immediately. It cannot make it readable, because a vault key is sealed to each member's public key and +this server never holds one — so until somebody presses **SHARE KEY** from a machine that has the key, their +vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed over later; it +is what "the operator cannot read the credentials it stores" costs, and the screen says so rather than +implying the server handed anything out. Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain from the first entry, and refuses unless the key the directory just offered appears in that log unchanged. @@ -246,14 +258,14 @@ published in a log every other client also reads. **It does not prove the key is Compare the fingerprint with them over something this server does not carry; that is the only step that closes it, and the success message says so every time. -**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD -MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's -account table rather than about what you are trying to do. If the directory knows the address, that account -is added straight away. If it does not, the address is invited instead, and the status line says which -happened, because the difference decides what you do next. +**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD** — +and it does whichever of the two applies, because which one applies is a fact about the server's account +table rather than about what you are trying to do. If the directory knows the address, that account is added +straight away. If it does not, the address is invited instead, and the status line says which happened, +because the difference decides what you do next. An invitation is a standing instruction rather than a message: the next account that signs in with that -address joins this team, at the role you chose. There is no link and no token, because this server has no +address joins this vault, at the role you chose. There is no link and no token, because this server has no outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a channel this server does not carry, and a link nobody can deliver would be worse than no link. An invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a @@ -265,9 +277,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful sequence is still the same one: add them, see them appear in the members list, then share. -Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that +Inviting an address that already belongs to a member of the vault is refused and says so. Inviting one that merely *has* an account here is not — that would make this a way of asking the server which addresses have -accounts, which is not a question anybody willing to create a team first should be able to put to it. Such +accounts, which is not a question anybody willing to create a vault first should be able to put to it. Such an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here, rather than waiting for a first sign-in that has already happened. @@ -275,39 +287,34 @@ rather than waiting for a first sign-in that has already happened. to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody -else's address is a way into a team. **If your invitations never activate, this is the first thing to +else's address is a way into a vault. **If your invitations never activate, this is the first thing to check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token rather than only in the ID token or the userinfo response. -**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they -become owner and you become an admin, in a single transaction. Not two role changes — promoting first -leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the -authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep -your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody -handing over a team is usually staying in it. +**Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member: +they become owner and you become an admin, in a single transaction. Not two role changes — promoting first +leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to +finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault +key grants; removing you would revoke them and flag the vault for rekey, and somebody handing a vault over +is usually staying in it. -**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team -vault is readable *because* of membership, so archiving a team that still owned vaults would take them away -from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a -vault, so there is no order of operations that gets past the refusal today, and it says so with a count of -what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its -outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug -cannot, because a slug is unique only among live teams and a rename could take one an archived team is -still holding. +Five limits, stated rather than discovered: -Four limits, stated rather than discovered: - -- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey, - and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials - that matter — that is the actual remediation, and it is why there is no button labelled anything stronger. +- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the + membership list behind a vault that still exists — a shared vault is readable *because* of membership, so + archiving it would take the vault away from everybody holding a key, including you, quietly and all at + once. The screen says so where you would otherwise go looking for the button. +- **Removing a member is not retroactive.** It revokes their grants and flags the vault for rekey, and + blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials that + matter — that is the actual remediation, and it is why there is no button labelled anything stronger. - **The rekey is flagged, never performed.** See the milestone note above. -- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used - from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will - then trust silently for a host you defined. The cost is that each member approves a team host's key once - on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what - has been trusted. +- **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and + used from your own vault, not the shared one, so a colleague cannot pre-approve a fingerprint that your + client will then trust silently for a host you defined. The cost is that each member approves a shared + host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins screen, so + you can see what has been trusted. - **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value an hour old means "recently" and not "at that moment". That is the granularity the question is really asked at — whether somebody is still using this deployment — and writing it on every request would put an @@ -315,9 +322,14 @@ Four limits, stated rather than discovered: as roughly-when rather than to the minute, because showing it to the minute would be reading a precision into it that is not there. -Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors -show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team -vault is visible to everybody in that team and moving it back means deleting and retyping. +Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's +editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two +vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it +again — and the picker is therefore absent when you edit an existing host rather than present and refusing. +Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen. +Both default to your personal vault and neither moves on its own, because an item put in a shared vault is +visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups +it can be filed under: a group is an item like any other and lives in exactly one vault. ### The Android head @@ -329,7 +341,7 @@ fit 360dp. Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP, -S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves +S3 buckets, logs, vaults and preferences behind Settings. A bottom bar is for the places a session moves between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see @@ -369,9 +381,9 @@ confirmation — without offering to change it. What this head does make, it mak rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning -on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not -have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a -team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on +on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not +have anticipated: an invitation is claimed by signing in, so somebody being told they have been added to a +vault is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on a head they have not installed is a membership they cannot see. **Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than @@ -570,15 +582,16 @@ keychain plus a terminal — and the spike that gates all of it. directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a ranged GET is part of the protocol, which is the one place a bucket beats SFTP. -- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the - append-only key log served for clients to verify against, team-owned vaults, and vault key grants - wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team +- **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key + directory, the append-only key log served for clients to verify against, shared vaults, and vault key + grants wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs - every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed, - handed to another member, and archived once it owns no vaults; a member row carries when that account was - last here; and an address with no account on this deployment can be invited, joining the moment somebody - signs in with it. See - [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth + every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is + vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the + team is behind the vault rather than a thing anybody has to create first. A vault can be renamed and + handed to another member; a member row carries when that account was last here; and an address with no + account on this deployment can be invited, joining the moment somebody signs in with it. See + [Sharing a vault](#sharing-a-vault) for the one distinction the whole design rests on, and the limits worth knowing before you rely on it; the reasoning is in [ADR 0009](docs/adr/0009-team-access-model.md). diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md index 802856c..9397adf 100644 --- a/docs/adding-hosts-on-the-phone.md +++ b/docs/adding-hosts-on-the-phone.md @@ -55,7 +55,7 @@ Three of those fields do not exist at any layer, and one of them is refused on t | Asked for | What exists today | | --- | --- | | The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* | -| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's `VaultScreen.axaml` already draws it. | +| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. | | Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. | | Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. | | A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. | diff --git a/docs/adr/0009-team-access-model.md b/docs/adr/0009-team-access-model.md index 8b1efcd..1bb0eb1 100644 --- a/docs/adr/0009-team-access-model.md +++ b/docs/adr/0009-team-access-model.md @@ -115,6 +115,36 @@ Three decisions inside it belong here, because each had a more convenient altern an address already belonging to a member of *this* team is refused, and that is a fact the caller can already read off the members table, so naming it leaks nothing. +### Addendum: the vault is what the product shows, and the team is behind it + +The model above is unchanged. What changed afterwards is which half of it a person is asked about. + +The first interface built on this ADR made the team the subject: you created a team, then a vault in it, +then wrapped a key. Two of those three steps are about a concept nobody arrives wanting. So the screen now +lists **vaults**, and naming one creates the membership list that carries it — named after the vault, +owned by the creator, one per vault. Nothing on the server moved: `VaultAccessService` still resolves a +shared vault through `team_membership`, every membership call still names a team id, and the split this +ADR is about — membership authorises, a grant unlocks — is still what the screen is built around, now +stated per vault rather than per team. + +Three consequences of the change belong here: + +- **A team owning several vaults is still legal and is no longer produced.** The client cannot make one; + an operator or a pre-existing deployment can. The screen refuses to hide it: a vault whose membership + list carries others says so, because on a vault-shaped screen "adding somebody here adds them there" is + precisely the fact that would otherwise be invisible. +- **Archiving left the interface.** It was only ever possible for a team owning no vaults, and a screen + whose rows are vaults has no row for one — so the button would have been unreachable or always refused. + The endpoint is unchanged and the screen states the limit instead. The one place a vault-less team can + still appear is a create whose second call failed; cancelling that form archives it, which is a + deliberate exception to this client's rule against tidying up on the user's behalf, made because nothing + else can reach it. +- **A vault can be renamed**, which it could not before: `PUT /api/v1/vaults/{id}` requires + `PermissionFlags.Admin` — the line `UpdateTeamEndpoint` already draws, because a name is what everybody + in the vault sees it called rather than part of its contents. It renames the owning team with it when + that team carries nothing else, so the row an operator reads and the name a user says do not drift + apart. The slug never moves, for the reason it never moves on a team rename. + ## Consequences The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index cf55bb8..53b82c3 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -29,16 +29,16 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`; > SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer. > -> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the +> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the > design had no slot for — and it is on the phone for a reason the design could not have anticipated, > because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the -> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the +> person being invited is at least as likely to be holding a phone as sitting at a desktop; a vault the > server has just put somebody in, visible only on a head they may not have installed, is a membership > they cannot see. It runs over the same view model the desktop screen drives, like the other four. > > | v2 element | What ships instead | > | --- | --- | -> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. | +> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. | > | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. | > | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | > | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | @@ -84,7 +84,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | **Split ⌘D** | Still omitted — the renderer stacks panes and shows one; tiling needs a pane geometry it has not got. | > | macOS traffic lights, and `⌘K` | The window's own minimise/maximise/close, and `CTRL K`. Development is Windows-first and the chrome is `BorderOnly` for a documented reason. | > | No status bar | Kept, and cut down to the one thing the titlebar does not now carry: `Vault.Status`, which is the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of a working afternoon and has nowhere to put a sentence like that. | -> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Teams and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. | +> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Vaults and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. | > > ## The desktop's v3 > @@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. | > | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. | > | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. | -> | The **chevron beside the vault name** | The name alone. An item cannot be moved between vaults: the two are encrypted under different keys, so moving one is a delete and a retype. Where a *new* item is filed is chosen on the keychain screen, which is the only vault question with an answer. | +> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes — is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. | > | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. | > | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. | > | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. | @@ -188,7 +188,7 @@ this document where what shipped differs from what the row predicted. **~~Teams are schema and nothing else.~~ Built in M3.** The `team` and `team_membership` tables were there from the first migration with nothing reading them, and `VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. Both changed in M3 and neither needed a migration, which is what carrying two -unused tables bought. See [Teams](#teams). What has *not* changed is the split underneath: the server +unused tables bought. See [Vaults](#vaults). What has *not* changed is the split underneath: the server decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on this screen, not one. @@ -282,7 +282,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | -| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the TEAMS screen, where they are about something. | +| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. | | `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | | `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | @@ -358,15 +358,17 @@ transfer primitive. The queue does its own 64 KiB copy loop and shares nothing w --- -## Vault +## Keychain — the design's Vault screen The screen ships and is real: four categories over the vault's four item types, one table, a detail pane, -and both editors. What follows is what the design drew around them. +and both editors. It is called the Keychain in both heads, which is what the rail has always labelled it; +the section below keeps the design's word only where it quotes the design. What follows is what the design +drew around them. | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | | `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. | -| `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the TEAMS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. | +| `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the VAULTS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. | | "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. | | Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | **Built in M3.** `VaultAccessService.ListAsync` resolves team membership, so a session can hold several vaults. | Not a rail, because it would be a selector with nothing to select: every list on the screen already spans every vault this session can read. What replaces it is a picker for where a *new* item is filed, which is the only vault question with an answer. | | `SCOPE` column | server | **Built in M3.** Scope is a property of a vault, never of an item, and that has not changed. | Each row names the vault it is in, and rows are grouped by vault. | @@ -379,20 +381,30 @@ and both editors. What follows is what the design drew around them. | `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. | | `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. | | `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. | -| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the TEAMS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. | +| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the VAULTS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. | | `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. | | One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. | | — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. | --- -## Teams +## Vaults -**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the -invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons -the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed -to another member, and archived, the last only while it owns no vaults. What follows is what it still does -not do, and one thing this document got wrong before it was built. +**Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real +last-active column, the invitations standing against addresses that have no account here yet, who holds a +key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault +can also be renamed and handed to another member. + +**It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server +authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership +list that carries it, named after the vault and owned by its creator, so the thing people came to share is +the thing they create. The team resurfaces in exactly one place and is not hidden there: a membership list +carrying several vaults — which this screen cannot produce — says so, because adding somebody to one of +those vaults adds them to all of them. Archiving is gone with the team list: a vault cannot be deleted at +all, the server refuses to archive a membership list while its vault exists, so the screen says so rather +than offering a button that always refuses. + +What follows is what it still does not do, and one thing this document got wrong before it was built. **The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that `VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 7af5f01..19a75b7 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -28,7 +28,7 @@ a phase had nothing left for a person to do, which is the good outcome rather th ### 1.1 No screen is sliced at the WebView's left edge · **the important one** -Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams, +Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults, Preferences — and both of the fixed tabs, SFTP and S3. **Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all @@ -1130,7 +1130,7 @@ case, and those two have to move together — the switch mirrors that property b --- -## Phase 12 — Teams: the operations that span two accounts +## Phase 12 — Shared vaults: the operations that span two accounts The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with @@ -1147,16 +1147,16 @@ follows is about what happens the first time it does. ### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care** -1. Sign in as `alice`, make a team, and open its invitations. -2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** — +1. Sign in as `alice`, make a vault on the VAULTS screen, and select it. +2. Add `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** — no "invitation emailed", no link to copy, no token anywhere on the screen. 3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out. Bob is **not** in the members table, because he has no account here for a membership row to point at. 4. Sign in as `bob` on the second profile and enroll. -5. **Pass:** the team is in Bob's list the first time he looks, at Member, with nothing further pressed on - either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, +5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed + on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, and Bob is now in the members table. -6. **Pass, and this is the half that is easiest to lose:** the team's vault is in Bob's list **saying it is +6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it opens. @@ -1171,15 +1171,15 @@ reached that machine by a route this architecture says does not exist. In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in. Invite that address, then sign in as them. -**Pass:** they get an account and a personal vault and no team at all. The invitation stays *pending* on +**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on the inviter's screen rather than turning into anything, and the API log carries a warning naming how many invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not hurry it along — the account already exists, so there is no second first-sign-in to trigger it. -**Failure means:** if the team appears while the address is unverified, the one security boundary +**Failure means:** if the vault appears while the address is unverified, the one security boundary invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address -can walk into their team. Stop there. If it stays pending after verifying, the claim is not reaching the +can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the **access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim under some other name. @@ -1188,27 +1188,27 @@ under some other name. Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address. **Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces -an ordinary account in no team. Revoking one that has *already* been accepted answers that there was -nothing to withdraw. +an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there +was nothing to withdraw. **Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An accepted one that could be unpicked here would be worse: it is a membership now, and removing a member -revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation" +revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation" should quietly do. ### 12.4 An address already in the team is refused; an address that merely has an account is not -With Bob in the team, invite `bob@example.com` to it again. +With Bob in the vault, add `bob@example.com` to it again. **Pass:** refused, with a sentence saying the address already belongs to a member and to change their role -instead. Now make a **second** team and invite the same address there. +instead. Now make a **second** vault and invite the same address there. **Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked. **Failure means:** if the second invitation is refused because the address already has an account, this endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody -willing to create a team first. See ADR 0009. +willing to create a vault first. See ADR 0009. ### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours** @@ -1227,49 +1227,53 @@ impossible to offer honestly before. As the owner, transfer ownership to another active member, then read both rows. -**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact -and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a -member, and to yourself. +**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact +and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and +to yourself. **Pass:** both refused, and the message says which. **Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and -either one leaves a team that no client can administer back into shape. If your grants were revoked or the -vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them. +either one leaves a vault that no client can administer back into shape. If your grant was revoked or the +vault is now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them. -### 12.7 Archiving is refused while the team owns a vault +### 12.7 A vault cannot be deleted, and the screen says so rather than offering a button -With a team that owns at least one vault, try to archive it. +Look for a way to remove a vault, on both heads. -**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault -in this product. The team is still in everybody's list afterwards and its vaults still open. +**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a +vault, and the server refuses to archive the membership list behind one while it exists. Archiving that +list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what +is being checked here is that no button offers it. -**Failure means:** an archive that succeeded here would have taken those vaults out of the list of -everybody holding a key — including the person who pressed it, quietly, and with nothing in the product -able to put them back. +**Failure means:** a delete that worked would take the vault out of the list of everybody holding a key — +including the person who pressed it, quietly, and with nothing in the product able to put them back. A +button that always refuses is the milder failure and is still worth removing. -### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts** +### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts** -Make a team that owns no vaults, add the second account to it, invite a third address, and archive it. +Rename a shared vault from the VAULTS screen. -**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it -joins nothing. A new team can be created under the archived one's slug. +**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain +screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu — +and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key +still opens it. -**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending -invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in -nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason -an archived team is only restorable by an operator who checks that first. +**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to +fail in, because several screens read it separately from a cached vault row. A vault that stops opening +after a rename would be far worse, and means that cached row was replaced by the server's answer rather +than edited — that answer deliberately carries no wrapped key. -### 12.9 Renaming a team, and the slug that does not move +### 12.9 Somebody who may write to a vault may not rename it -Rename a team and change its description. +As a plain Member of somebody else's vault, look for RENAME. -**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is -unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed. +**Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary +rather than a broken role. -**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that -archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented -data — `team` has no updated-at column, so there is nothing behind it. +**Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from +under the people who share it is an administrative act reached without the role for it. The server refuses +it too — this is the interface not offering what the server would turn down. --- diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs index 8480ea6..459c5f2 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamLog.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs @@ -50,6 +50,16 @@ internal static partial class TeamLog internal static partial void TeamVaultCreated( ILogger logger, Guid vaultId, Guid teamId, Guid actorId); + /// + /// The vault and its team, and no names. A vault name is plaintext on this server, which is not a + /// reason to copy it into everything a log aggregator keeps for a year. + /// + [LoggerMessage( + EventId = 2115, + Level = LogLevel.Information, + Message = "Renamed vault {VaultId} of team {TeamId}.")] + internal static partial void VaultRenamed(ILogger logger, Guid vaultId, Guid? teamId); + [LoggerMessage( EventId = 2106, Level = LogLevel.Information, diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs index 97b2289..f832e12 100644 --- a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs @@ -50,6 +50,78 @@ internal sealed class ListVaultGrantsEndpoint( } } +/// Renames a vault. +/// +/// +/// Admin rather than Write, and the line is the one UpdateTeamEndpoint draws: a name is what +/// everybody in the vault sees it called, so changing it is an administrative act rather than an edit +/// to the vault's contents. A member who may add hosts to a shared vault may not rename it out from +/// under the people who share it. +/// +/// +/// Authenticated rather than Enrolled, unlike everything else about a vault here. A rename touches no +/// key material and needs none — somebody added to a team before they have finished setting their own +/// machine up can still be reading this screen — and requiring a published identity key would refuse +/// them for a reason that has nothing to do with what they are asking. +/// +/// +internal sealed class RenameVaultEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + // PUT, for the reason the team rename is a PUT: one field, always sent whole, and a repeat is + // the same vault rather than a second edit. + Put("/api/v1/vaults/{vaultId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("RenameVault") + .WithSummary("Renames a vault.") + .WithTags("Vaults")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + UpdateVaultRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("vaultId"), ct) + .ConfigureAwait(false); + + if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) + { + return TypedResults.NotFound(); + } + + if (!access.Permissions.HasFlag(PermissionFlags.Admin)) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of the team that owns this vault can rename it."); + } + + try + { + return TypedResults.Ok( + await grants.RenameVaultAsync(access.Vault!, req, ct).ConfigureAwait(false)); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + /// Wraps this vault's key to another member. /// /// The one call in this API whose body the server can neither produce nor check. It stores a sealed diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs index 311e31f..bf84e41 100644 --- a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs @@ -151,6 +151,71 @@ internal sealed class VaultGrantService( : name; } + /// + /// Renames a vault, and the team behind it where that team exists to carry this vault alone. + /// + /// + /// + /// The team is renamed with it, and only when it owns nothing else. A vault made from the + /// vaults screen gets a team of its own named after it, and that team is not a thing the person who + /// made it was ever shown — so a rename that moved the vault's name and left the team's would leave + /// the operator, the logs and the database naming it something nobody uses. A team owning several + /// vaults is a different situation: it has a name of its own that somebody chose, and renaming one of + /// its vaults must not take it. + /// + /// + /// The slug never moves, exactly as UpdateTeamRequest records: it is unique only among live + /// teams, so a rename that changed it could take one an archived team is still holding. + /// + /// + internal async Task RenameVaultAsync( + Vault vault, + UpdateVaultRequest request, + CancellationToken cancellationToken) + { + var name = RequireVaultName(request.Name); + + vault.Name = name; + vault.UpdatedAtUtc = clock.GetUtcNow(); + + if (vault.TeamId is { } teamId) + { + var alone = !await database.Vaults + .AnyAsync( + other => other.TeamId == teamId + && other.Id != vault.Id + && other.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + if (alone) + { + var team = await database.Teams + .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken) + .ConfigureAwait(false); + + if (team is not null) + { + team.Name = name; + } + } + } + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.VaultRenamed(logger, vault.Id, vault.TeamId); + + return new VaultSummary( + VaultId: vault.Id, + Name: name, + IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal, + TeamId: vault.TeamId, + KeyGeneration: (uint)vault.KeyGeneration, + Permissions: 0, + WrappedVaultKey: null, + RekeyRequired: vault.RekeyRequired); + } + /// Lists who can open a vault. internal async Task ListGrantsAsync( Vault vault, diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 7f63319..c9e266f 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -56,6 +56,7 @@ internal static class EndpointRegistration typeof(CreateTeamInvitationEndpoint), typeof(RevokeTeamInvitationEndpoint), typeof(CreateTeamVaultEndpoint), + typeof(RenameVaultEndpoint), typeof(ListVaultGrantsEndpoint), typeof(IssueVaultGrantEndpoint), typeof(RevokeVaultGrantEndpoint), diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index 1a8d0e8..821f6f2 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -260,6 +260,28 @@ + + + + + + + + + + + + + - diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml index 1def439..011adf4 100644 --- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml +++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml @@ -191,7 +191,7 @@ + Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." /> diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs deleted file mode 100644 index 190e15d..0000000 --- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Avalonia.Controls; - -namespace DodoSSH.Client.App.Views; - -/// Teams: who is in one, what they may do, and which vaults they hold a key to. -internal sealed partial class TeamsScreen : UserControl -{ - public TeamsScreen() => InitializeComponent(); -} diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml index c8d6c68..e23bcf1 100644 --- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml +++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml @@ -162,7 +162,7 @@ HorizontalContentAlignment="Left" Content="New vault…" Click="OnNewVaultPressed" - ToolTip.Tip="Names a vault and makes a team to own it, so you can invite people to it and give them roles" /> + ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" /> diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml similarity index 57% rename from src/DodoSSH.Client.App/Views/TeamsScreen.axaml rename to src/DodoSSH.Client.App/Views/VaultsScreen.axaml index 013dc1c..3b3e3c8 100644 --- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml +++ b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml @@ -2,141 +2,140 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:contracts="using:DodoSSH.Contracts" - x:Class="DodoSSH.Client.App.Views.TeamsScreen" - x:DataType="vm:TeamsViewModel"> + x:Class="DodoSSH.Client.App.Views.VaultsScreen" + x:DataType="vm:VaultsViewModel"> - + -