@jpgilldev / steid

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