steid

@jamesgill /

a814db5feat: push and clone private repositories with a token8d
1//! Personal access tokens — `/{handle}/settings/tokens`.
2//!
3//! The one place a token is ever visible. Everything else in Steid holds only its hash,
4//! so if this page does not show it, nobody can.
5
6use std::time::SystemTime;
7
8use serde::Deserialize;
9use topcoat::{
10 Result,
11 context::Cx,
12 router::{StatusCode, content::Form, error::forbidden, page, path_param},
13 view::{attributes, component, view},
14};
15
16use crate::{
17 application::{Error, TokenSummary, issue_token, list_tokens, revoke_token},
18 components::{
19 badge::{BadgeVariant, badge},
20 button::button,
21 flash::{FlashKind, flash},
22 input::input,
23 label::label,
24 },
25 domain::{DomainError, TokenId},
26};
27
28use super::{
29 context::{current_actor, location, server_error, tokens},
5d3dfa5feat: a global top bar, and pages choose their own width22h
30 layout::narrow,
a814db5feat: push and clone private repositories with a token8d
31 profile::profile_for,
32};
33
34/// `{token}` from the path — a token's id, never the token itself.
35#[path_param]
36struct Token(str);
37
38#[derive(Debug, Deserialize)]
39struct IssueForm {
40 name: String,
41}
42
43/// Only the person whose settings these are may see them.
44///
45/// The use cases scope every operation to the actor's own tokens regardless, so this
46/// guard decides what is *shown*, not what is allowed.
47async fn own_settings(cx: &Cx) -> Result<String> {
48 let profile = profile_for(cx).await?;
49
50 if !profile.viewer_is_owner {
51 return Err(forbidden().into());
52 }
53
54 Ok(profile.handle.to_string())
55}
56
57async fn listing(cx: &Cx) -> Result<Vec<TokenSummary>> {
58 list_tokens(&current_actor(cx).await?, &tokens(cx))
59 .await
60 .map_err(server_error)
61}
62
63#[page("/{handle}/settings/tokens")]
64async fn tokens_page(cx: &Cx) -> Result {
65 let handle = own_settings(cx).await?;
66 let listed = listing(cx).await?;
67
68 view! {
69 tokens_view(
70 handle: handle.as_str(),
71 tokens: &listed,
72 issued: "",
73 error: "",
74 name: "",
75 )
76 }
77}
78
79/// Issues a token and shows it, once.
80///
81/// **This deliberately does not redirect**, unlike every other form in Steid. The
82/// secret exists only in this response: surviving a redirect would mean putting a live
83/// credential in a URL, where it lands in browser history, logs and referrers. Reloading
84/// re-submits, which issues a second token — harmless, and visible in the list.
85#[page(POST "/{handle}/settings/tokens")]
86async fn issue(cx: &Cx, Form(submitted): Form<IssueForm>) -> Result {
87 let handle = own_settings(cx).await?;
88
89 let outcome = issue_token(
90 &current_actor(cx).await?,
91 &submitted.name,
92 SystemTime::now(),
93 &tokens(cx),
94 )
95 .await;
96
97 let (issued, error) = match outcome {
98 Ok(issued) => (issued.secret.reveal().to_owned(), String::new()),
99 Err(Error::Domain(DomainError::Validation { field, reason })) => {
100 (String::new(), format!("That {field} is no good: {reason}."))
101 }
102 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
103 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
104 };
105
106 let listed = listing(cx).await?;
107
108 view! {
109 tokens_view(
110 handle: handle.as_str(),
111 tokens: &listed,
112 issued: issued.as_str(),
113 error: error.as_str(),
114 // Cleared on success so the field is ready for the next one, kept on failure
115 // so a rejected name is not silently thrown away.
116 name: if issued.is_empty() { submitted.name.as_str() } else { "" },
117 )
118 }
119}
120
121/// Revokes a token, then redirects so a reload cannot repeat it.
122#[page(POST "/{handle}/settings/tokens/{token}/revoke")]
123async fn revoke(cx: &Cx) -> Result {
124 let handle = own_settings(cx).await?;
125 let id = TokenId::from_trusted(path_param::<Token>(cx));
126
127 match revoke_token(&current_actor(cx).await?, &id, &tokens(cx)).await {
128 Ok(()) => {}
129 // Someone else's token, or none at all. Both are "no such token of yours".
130 Err(Error::Domain(DomainError::NotFound { .. })) => {}
131 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
132 }
133
134 view! {
135 (StatusCode::SEE_OTHER)
136 (location(&format!("/{handle}/settings/tokens"))?)
137 }
138}
139
140#[component]
141async fn tokens_view(
142 handle: &str,
143 tokens: &[TokenSummary],
144 issued: &str,
145 error: &str,
146 name: &str,
147) -> Result {
148 view! {
5d3dfa5feat: a global top bar, and pages choose their own width22h
149 narrow(
150 <h1 class="text-xl font-semibold tracking-tight">"Access tokens"</h1>
151 <p class="mt-1 text-sm text-muted-foreground">
152 "Use a token as the password when git asks. Your username can be anything."
153 </p>
154
155 if !issued.is_empty() {
156 <div class="mt-6 space-y-2">
157 flash(
158 kind: FlashKind::Success,
159 "Copy this now — it is not shown again."
160 )
161 <pre class="overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(issued)</pre>
162 </div>
163 }
a814db5feat: push and clone private repositories with a token8d
164
5d3dfa5feat: a global top bar, and pages choose their own width22h
165 if !error.is_empty() {
166 <div class="mt-6">
167 flash(kind: FlashKind::Error, (error))
168 </div>
a814db5feat: push and clone private repositories with a token8d
169 }
5d3dfa5feat: a global top bar, and pages choose their own width22h
170
171 <form method="post" action=(format!("/{handle}/settings/tokens")) class="mt-6 space-y-5">
172 <div class="space-y-2">
173 label(attrs: attributes! { for="name" }, "Token name")
174 input(attrs: attributes! {
175 id="name"
176 name="name"
177 type="text"
178 value=(name)
179 placeholder="laptop"
180 required=(true)
181 })
182 <p class="text-xs text-muted-foreground">
183 "So you can tell which one to revoke later."
184 </p>
185 </div>
186
187 button(attrs: attributes! { type="submit" }, "Create token")
188 </form>
189
190 <section class="mt-10">
191 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
192 "Your tokens"
193 </h2>
194
195 if tokens.is_empty() {
196 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
197 "No tokens yet."
198 </p>
199 } else {
200 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
201 for token in tokens {
202 <li class="flex items-center justify-between gap-4 px-4 py-3">
203 <div class="flex items-baseline gap-2">
204 <span class="font-medium">(token.name.as_str())</span>
205 badge(
206 variant: BadgeVariant::Outline,
207 "steid_pat_" (token.prefix.as_str()) ""
208 )
209 </div>
210 <form
211 method="post"
212 action=(format!("/{handle}/settings/tokens/{}/revoke", token.id))
213 >
214 button(
215 attrs: attributes! { type="submit" },
216 "Revoke"
217 )
218 </form>
219 </li>
220 }
221 </ul>
222 }
223 </section>
224
225 <p class="mt-8">
226 <a
227 href=(format!("/{handle}/settings"))
228 class="text-sm text-muted-foreground hover:text-foreground"
229 >"Back to settings"</a>
230 </p>
231 )
a814db5feat: push and clone private repositories with a token8d
232 }
233}