| 1 | //! Settings pages — the user's own, and a repository's (spec §7). | |
| 2 | //! | |
| 3 | //! Every form here is a plain `<form method="post">`. Spec §7 makes progressive | |
| 4 | //! enhancement a hard requirement, and settings are exactly where a | |
| 5 | //! JavaScript-only affordance would be most annoying to hit. | |
| 6 | //! | |
| 7 | //! Note the deletion forms POST rather than sending `DELETE`. The spec's route | |
| 8 | //! table says `DELETE /settings/keys/{id}`, and that route exists for API | |
| 9 | //! clients — but browsers cannot emit it from a form without JavaScript, so the | |
| 10 | //! POST alias is what the UI uses. Both are registered; both do the same thing. | |
| 11 | ||
| 12 | use maud::{html, Markup}; | |
| 13 | ||
| 14 | use crate::repo_ctx::RepoContext; | |
| 15 | ||
| 16 | // ─── the user's own settings ───────────────────────────────────────────────── | |
| 17 | ||
| 18 | pub struct SshKeyRow { | |
| 19 | pub id: uuid::Uuid, | |
| 20 | pub name: String, | |
| 21 | pub key_type: String, | |
| 22 | pub fingerprint: String, | |
| 23 | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 24 | } | |
| 25 | ||
| 26 | pub struct TokenRow { | |
| 27 | pub id: uuid::Uuid, | |
| 28 | pub name: String, | |
| 29 | pub prefix: String, | |
| 30 | pub expires_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 31 | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 32 | } | |
| 33 | ||
| 34 | pub struct UserSettings<'a> { | |
| 35 | pub user: &'a df_db::models::User, | |
| 36 | pub csrf: &'a str, | |
| 37 | pub keys: &'a [SshKeyRow], | |
| 38 | pub tokens: &'a [TokenRow], | |
| 39 | /// A token's plaintext, shown exactly once immediately after minting it. | |
| 40 | pub new_token: Option<&'a str>, | |
| 41 | pub error: Option<&'a str>, | |
| 42 | pub notice: Option<&'a str>, | |
| 43 | pub ssh_host: &'a str, | |
| 44 | } | |
| 45 | ||
| 46 | pub fn user_settings(s: UserSettings<'_>) -> Markup { | |
| 47 | html! { | |
| 48 | h1 { "Settings" } | |
| 49 | ||
| 50 | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } | |
| 51 | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } | |
| 52 | ||
| 53 | div .panel { | |
| 54 | h2 { "Profile" } | |
| 55 | dl .kv { | |
| 56 | dt { "Handle" } dd { code { (s.user.handle) } } | |
| 57 | dt { "Name" } dd { (s.user.display_name.as_deref().unwrap_or("—")) } | |
| 58 | dt { "Email" } dd { (s.user.email.as_deref().unwrap_or("—")) } | |
| 59 | dt { "Role" } dd { @if s.user.is_admin { "Site administrator" } @else { "Member" } } | |
| 60 | } | |
| 61 | p .hint { | |
| 62 | "Your name and email come from the identity provider you sign in with, \ | |
| 63 | and change there rather than here." | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | // ─── SSH keys ──────────────────────────────────────────────────────── | |
| 68 | div .panel { | |
| 69 | h2 { "SSH keys" } | |
| 70 | p .dim { | |
| 71 | "Push over SSH with " | |
| 72 | code { "jj git push" } " or " code { "git push" } ". Clone URLs look like " | |
| 73 | code { (s.ssh_host) } | |
| 74 | "." | |
| 75 | } | |
| 76 | ||
| 77 | @if s.keys.is_empty() { | |
| 78 | p .hint { "No keys yet." } | |
| 79 | } @else { | |
| 80 | table .listing { | |
| 81 | thead { | |
| 82 | tr { th scope="col" { "Name" } th scope="col" { "Fingerprint" } th scope="col" { "Last used" } th scope="col" {} } | |
| 83 | } | |
| 84 | tbody { | |
| 85 | @for k in s.keys { | |
| 86 | tr { | |
| 87 | td { (k.name) " " span .chip { (k.key_type) } } | |
| 88 | td { code .faint { (k.fingerprint) } } | |
| 89 | td .faint { (opt_date(k.last_used_at)) } | |
| 90 | td { | |
| 91 | form method="post" action=(format!("/settings/keys/{}/delete", k.id)) { | |
| 92 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 93 | button .btn.btn-danger type="submit" { "Remove" } | |
| 94 | } | |
| 95 | } | |
| 96 | } | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | form method="post" action="/settings/keys" .stack { | |
| 103 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 104 | div .field { | |
| 105 | label for="key" { "New key" } | |
| 106 | textarea id="key" name="key" rows="4" required | |
| 107 | placeholder="ssh-ed25519 AAAA… you@your-machine" {} | |
| 108 | p .hint { | |
| 109 | "Paste the contents of your " | |
| 110 | code { ".pub" } | |
| 111 | " file — never the private key." | |
| 112 | } | |
| 113 | } | |
| 114 | div .field { | |
| 115 | label for="key_name" { "Name (optional)" } | |
| 116 | input type="text" id="key_name" name="name" maxlength="100" | |
| 117 | placeholder="taken from the key's comment"; | |
| 118 | } | |
| 119 | button .btn.btn-primary type="submit" { "Add key" } | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | // ─── access tokens ─────────────────────────────────────────────────── | |
| 124 | div .panel { | |
| 125 | h2 { "Access tokens" } | |
| 126 | p .dim { | |
| 127 | "For Git over HTTPS. Use your handle as the username and the token as \ | |
| 128 | the password." | |
| 129 | } | |
| 130 | ||
| 131 | @if let Some(t) = s.new_token { | |
| 132 | div .banner.banner-ok role="status" { | |
| 133 | p { strong { "Copy this now — it is not shown again." } } | |
| 134 | div .clone-box { code { (t) } } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | @if s.tokens.is_empty() { | |
| 139 | p .hint { "No tokens yet." } | |
| 140 | } @else { | |
| 141 | table .listing { | |
| 142 | thead { | |
| 143 | tr { th scope="col" { "Name" } th scope="col" { "Prefix" } th scope="col" { "Expires" } th scope="col" { "Last used" } th scope="col" {} } | |
| 144 | } | |
| 145 | tbody { | |
| 146 | @for t in s.tokens { | |
| 147 | tr { | |
| 148 | td { (t.name) } | |
| 149 | td { code .faint { (t.prefix) "…" } } | |
| 150 | td .faint { (opt_date(t.expires_at)) } | |
| 151 | td .faint { (opt_date(t.last_used_at)) } | |
| 152 | td { | |
| 153 | form method="post" action=(format!("/settings/tokens/{}/delete", t.id)) { | |
| 154 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 155 | button .btn.btn-danger type="submit" { "Revoke" } | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | form method="post" action="/settings/tokens" .stack { | |
| 165 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 166 | div .field { | |
| 167 | label for="token_name" { "Name" } | |
| 168 | input type="text" id="token_name" name="name" required maxlength="100" | |
| 169 | placeholder="laptop"; | |
| 170 | } | |
| 171 | div .field { | |
| 172 | label for="expires_days" { "Expires in" } | |
| 173 | select id="expires_days" name="expires_days" { | |
| 174 | option value="30" { "30 days" } | |
| 175 | option value="90" selected { "90 days" } | |
| 176 | option value="365" { "1 year" } | |
| 177 | option value="0" { "Never" } | |
| 178 | } | |
| 179 | } | |
| 180 | button .btn.btn-primary type="submit" { "Generate token" } | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | // ─── repository settings ───────────────────────────────────────────────────── | |
| 187 | ||
| 188 | pub struct CollaboratorRow { | |
| 189 | pub handle: String, | |
| 190 | pub role: df_db::models::RepoRole, | |
| 191 | } | |
| 192 | ||
| 193 | pub struct BookmarkRow { | |
| 194 | pub name: String, | |
| 195 | pub protected: bool, | |
| 196 | pub is_default: bool, | |
| 197 | } | |
| 198 | ||
| 199 | pub struct RepoSettings<'a> { | |
| 200 | pub ctx: &'a RepoContext, | |
| 201 | pub csrf: &'a str, | |
| 202 | pub tab: &'a str, | |
| 203 | pub collaborators: &'a [CollaboratorRow], | |
| 204 | pub bookmarks: &'a [BookmarkRow], | |
| 205 | pub size_bytes: u64, | |
| 206 | pub error: Option<&'a str>, | |
| 207 | pub notice: Option<&'a str>, | |
| 208 | } | |
| 209 | ||
| 210 | pub fn repo_settings(s: RepoSettings<'_>) -> Markup { | |
| 211 | let base = s.ctx.base(); | |
| 212 | let settings = format!("{base}/settings"); | |
| 213 | ||
| 214 | html! { | |
| 215 | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } | |
| 216 | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } | |
| 217 | ||
| 218 | nav .subtabs aria-label="Settings sections" { | |
| 219 | @for (key, label) in [("general", "General"), ("collaborators", "Collaborators"), | |
| 220 | ("bookmarks", "Bookmarks"), ("danger", "Danger zone")] { | |
| 221 | a href=(format!("{settings}?tab={key}")) | |
| 222 | .active[s.tab == key] | |
| 223 | aria-current=[(s.tab == key).then_some("page")] { (label) } | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | @match s.tab { | |
| 228 | "collaborators" => (collaborators_tab(&s)), | |
| 229 | "bookmarks" => (bookmarks_tab(&s)), | |
| 230 | "danger" => (danger_tab(&s)), | |
| 231 | _ => (general_tab(&s)), | |
| 232 | } | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | fn general_tab(s: &RepoSettings<'_>) -> Markup { | |
| 237 | let action = format!("{}/settings/general", s.ctx.base()); | |
| 238 | let private = matches!(s.ctx.repo.visibility, df_db::models::Visibility::Private); | |
| 239 | ||
| 240 | html! { | |
| 241 | div .panel { | |
| 242 | h2 { "General" } | |
| 243 | form method="post" action=(action) .stack { | |
| 244 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 245 | ||
| 246 | div .field { | |
| 247 | label for="description" { "Description" } | |
| 248 | input type="text" id="description" name="description" maxlength="500" | |
| 249 | value=(s.ctx.repo.description.as_deref().unwrap_or("")); | |
| 250 | } | |
| 251 | ||
| 252 | div .field { | |
| 253 | label for="default_bookmark" { "Default bookmark" } | |
| 254 | select id="default_bookmark" name="default_bookmark" { | |
| 255 | @for b in s.bookmarks { | |
| 256 | option value=(b.name) selected[b.is_default] { (b.name) } | |
| 257 | } | |
| 258 | // A repository with no pushes yet has no bookmark rows, | |
| 259 | // so keep the configured name selectable. | |
| 260 | @if s.bookmarks.is_empty() { | |
| 261 | option value=(s.ctx.repo.default_bookmark) selected { | |
| 262 | (s.ctx.repo.default_bookmark) | |
| 263 | } | |
| 264 | } | |
| 265 | } | |
| 266 | p .hint { "Where the code view opens, and what changes are measured against." } | |
| 267 | } | |
| 268 | ||
| 269 | div .field { | |
| 270 | label { | |
| 271 | input type="checkbox" name="private" value="1" checked[private]; | |
| 272 | " Private" | |
| 273 | } | |
| 274 | p .hint { | |
| 275 | "A private repository is invisible to everyone but its collaborators — \ | |
| 276 | it returns the same 404 a nonexistent repository does." | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | button .btn.btn-primary type="submit" { "Save" } | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | div .panel { | |
| 285 | h2 { "Storage" } | |
| 286 | dl .kv { | |
| 287 | dt { "On disk" } dd { (crate::views::repo::human_size(s.size_bytes)) } | |
| 288 | dt { "Created" } dd { (s.ctx.repo.created_at.format("%Y-%m-%d")) } | |
| 289 | dt { "Last push" } dd { (opt_date(s.ctx.repo.pushed_at)) } | |
| 290 | } | |
| 291 | } | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | fn collaborators_tab(s: &RepoSettings<'_>) -> Markup { | |
| 296 | let base = s.ctx.base(); | |
| 297 | html! { | |
| 298 | div .panel { | |
| 299 | h2 { "Collaborators" } | |
| 300 | p .dim { | |
| 301 | "Effective access is the highest of a direct role here, an organization \ | |
| 302 | role, and repository ownership." | |
| 303 | } | |
| 304 | ||
| 305 | @if s.collaborators.is_empty() { | |
| 306 | p .hint { "No collaborators. Only the owner can reach this repository." } | |
| 307 | } @else { | |
| 308 | table .listing { | |
| 309 | thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } } | |
| 310 | tbody { | |
| 311 | @for c in s.collaborators { | |
| 312 | tr { | |
| 313 | td { a href=(format!("/{}", c.handle)) { (c.handle) } } | |
| 314 | td { | |
| 315 | form method="post" action=(format!("{base}/settings/collaborators")) .row { | |
| 316 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 317 | input type="hidden" name="handle" value=(c.handle); | |
| 318 | select name="role" { | |
| 319 | @for r in ["read", "write", "maintain", "admin"] { | |
| 320 | option value=(r) selected[role_str(c.role) == r] { (r) } | |
| 321 | } | |
| 322 | } | |
| 323 | button .btn type="submit" { "Update" } | |
| 324 | } | |
| 325 | } | |
| 326 | td { | |
| 327 | form method="post" action=(format!("{base}/settings/collaborators/remove")) { | |
| 328 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 329 | input type="hidden" name="handle" value=(c.handle); | |
| 330 | button .btn.btn-danger type="submit" { "Remove" } | |
| 331 | } | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 335 | } | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | form method="post" action=(format!("{base}/settings/collaborators")) .stack { | |
| 340 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 341 | div .field { | |
| 342 | label for="handle" { "Add a collaborator" } | |
| 343 | input type="text" id="handle" name="handle" required | |
| 344 | pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle"; | |
| 345 | } | |
| 346 | div .field { | |
| 347 | label for="role" { "Role" } | |
| 348 | select id="role" name="role" { | |
| 349 | option value="read" { "read — browse and comment" } | |
| 350 | option value="write" selected { "write — also push" } | |
| 351 | option value="maintain" { "maintain — also manage changes and settings" } | |
| 352 | option value="admin" { "admin — also delete" } | |
| 353 | } | |
| 354 | } | |
| 355 | button .btn.btn-primary type="submit" { "Add" } | |
| 356 | } | |
| 357 | } | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | fn bookmarks_tab(s: &RepoSettings<'_>) -> Markup { | |
| 362 | let base = s.ctx.base(); | |
| 363 | html! { | |
| 364 | div .panel { | |
| 365 | h2 { "Bookmarks" } | |
| 366 | p .dim { | |
| 367 | "A protected bookmark cannot be deleted or force-updated by a push. \ | |
| 368 | The default bookmark is always protected." | |
| 369 | } | |
| 370 | ||
| 371 | @if s.bookmarks.is_empty() { | |
| 372 | p .hint { "Nothing has been pushed yet." } | |
| 373 | } @else { | |
| 374 | table .listing { | |
| 375 | thead { tr { th scope="col" { "Bookmark" } th scope="col" { "Protected" } th scope="col" {} } } | |
| 376 | tbody { | |
| 377 | @for b in s.bookmarks { | |
| 378 | tr { | |
| 379 | td { | |
| 380 | code { (b.name) } | |
| 381 | @if b.is_default { " " span .chip { "default" } } | |
| 382 | } | |
| 383 | td { @if b.protected || b.is_default { "yes" } @else { "no" } } | |
| 384 | td { | |
| 385 | @if !b.is_default { | |
| 386 | form method="post" action=(format!("{base}/settings/bookmarks")) { | |
| 387 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 388 | input type="hidden" name="name" value=(b.name); | |
| 389 | input type="hidden" name="protected" | |
| 390 | value=(if b.protected { "0" } else { "1" }); | |
| 391 | button .btn type="submit" { | |
| 392 | @if b.protected { "Unprotect" } @else { "Protect" } | |
| 393 | } | |
| 394 | } | |
| 395 | } @else { | |
| 396 | span .faint { "always" } | |
| 397 | } | |
| 398 | } | |
| 399 | } | |
| 400 | } | |
| 401 | } | |
| 402 | } | |
| 403 | } | |
| 404 | } | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | fn danger_tab(s: &RepoSettings<'_>) -> Markup { | |
| 409 | let base = s.ctx.base(); | |
| 410 | let full = format!("{}/{}", s.ctx.owner, s.ctx.repo.name); | |
| 411 | html! { | |
| 412 | div .panel.panel-danger { | |
| 413 | h2 { "Danger zone" } | |
| 414 | ||
| 415 | form method="post" action=(format!("{base}/settings/archive")) .stack { | |
| 416 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 417 | h3 { @if s.ctx.repo.archived { "Unarchive" } @else { "Archive" } } | |
| 418 | p .dim { | |
| 419 | "An archived repository is read-only: it still browses, but pushes \ | |
| 420 | are refused." | |
| 421 | } | |
| 422 | input type="hidden" name="archived" value=(if s.ctx.repo.archived { "0" } else { "1" }); | |
| 423 | button .btn type="submit" { | |
| 424 | @if s.ctx.repo.archived { "Unarchive repository" } @else { "Archive repository" } | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | hr; | |
| 429 | ||
| 430 | @if s.ctx.access.can_delete() { | |
| 431 | form method="post" action=(format!("{base}/settings/delete")) .stack { | |
| 432 | input type="hidden" name="_csrf" value=(s.csrf); | |
| 433 | h3 { "Delete this repository" } | |
| 434 | p .dim { | |
| 435 | "This removes the repository, its changes, its reviews, and every \ | |
| 436 | comment on them. It cannot be undone and the stored objects are \ | |
| 437 | deleted from disk." | |
| 438 | } | |
| 439 | div .field { | |
| 440 | label for="confirm" { | |
| 441 | "Type " code { (full) } " to confirm" | |
| 442 | } | |
| 443 | input type="text" id="confirm" name="confirm" required autocomplete="off"; | |
| 444 | } | |
| 445 | button .btn.btn-danger type="submit" { "Delete repository" } | |
| 446 | } | |
| 447 | } @else { | |
| 448 | p .hint { "Only a repository administrator can delete it." } | |
| 449 | } | |
| 450 | } | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 454 | // ─── owner profile ─────────────────────────────────────────────────────────── | |
| 455 | ||
| 456 | pub struct ProfileRepo { | |
| 457 | pub name: String, | |
| 458 | pub description: Option<String>, | |
| 459 | pub private: bool, | |
| 460 | pub pushed_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 461 | } | |
| 462 | ||
| 463 | pub struct Profile<'a> { | |
| 464 | pub handle: &'a str, | |
| 465 | pub display_name: Option<&'a str>, | |
| 466 | pub description: Option<&'a str>, | |
| 467 | pub is_org: bool, | |
| 468 | pub repos: &'a [ProfileRepo], | |
| 469 | /// Org members, when the profile is an organization. | |
| 470 | pub members: &'a [(String, df_db::models::OrgRole)], | |
| 471 | pub joined: chrono::DateTime<chrono::Utc>, | |
| 472 | } | |
| 473 | ||
| 474 | pub fn profile(p: Profile<'_>) -> Markup { | |
| 475 | html! { | |
| 476 | div .panel { | |
| 477 | div .row { | |
| 478 | h1 style="margin:0" { (p.display_name.unwrap_or(p.handle)) } | |
| 479 | @if p.is_org { span .chip { "organization" } } | |
| 480 | } | |
| 481 | p .dim { "@" (p.handle) } | |
| 482 | @if let Some(d) = p.description { p { (d) } } | |
| 483 | p .hint { "Here since " (p.joined.format("%B %Y")) "." } | |
| 484 | } | |
| 485 | ||
| 486 | div .panel { | |
| 487 | h2 { "Repositories" } | |
| 488 | @if p.repos.is_empty() { | |
| 489 | p .hint { "Nothing visible to you here." } | |
| 490 | } @else { | |
| 491 | div .stack { | |
| 492 | @for r in p.repos { | |
| 493 | div .row { | |
| 494 | a href=(format!("/{}/{}", p.handle, r.name)) { strong { (r.name) } } | |
| 495 | @if r.private { span .chip { "private" } } | |
| 496 | @if let Some(d) = &r.description { span .dim { (d) } } | |
| 497 | span .faint style="margin-left:auto" { | |
| 498 | @if let Some(t) = r.pushed_at { "pushed " (t.format("%Y-%m-%d")) } | |
| 499 | } | |
| 500 | } | |
| 501 | } | |
| 502 | } | |
| 503 | } | |
| 504 | } | |
| 505 | ||
| 506 | @if p.is_org && !p.members.is_empty() { | |
| 507 | div .panel { | |
| 508 | h2 { "Members" } | |
| 509 | div .stack { | |
| 510 | @for (handle, role) in p.members { | |
| 511 | div .row { | |
| 512 | a href=(format!("/{handle}")) { (handle) } | |
| 513 | span .chip { (org_role_str(*role)) } | |
| 514 | } | |
| 515 | } | |
| 516 | } | |
| 517 | } | |
| 518 | } | |
| 519 | } | |
| 520 | } | |
| 521 | ||
| 522 | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 523 | ||
| 524 | fn opt_date(t: Option<chrono::DateTime<chrono::Utc>>) -> String { | |
| 525 | t.map(|t| t.format("%Y-%m-%d").to_string()) | |
| 526 | .unwrap_or_else(|| "never".into()) | |
| 527 | } | |
| 528 | ||
| 529 | pub fn role_str(r: df_db::models::RepoRole) -> &'static str { | |
| 530 | use df_db::models::RepoRole::*; | |
| 531 | match r { | |
| 532 | Read => "read", | |
| 533 | Write => "write", | |
| 534 | Maintain => "maintain", | |
| 535 | Admin => "admin", | |
| 536 | } | |
| 537 | } | |
| 538 | ||
| 539 | fn org_role_str(r: df_db::models::OrgRole) -> &'static str { | |
| 540 | match r { | |
| 541 | df_db::models::OrgRole::Member => "member", | |
| 542 | df_db::models::OrgRole::Admin => "admin", | |
| 543 | } | |
| 544 | } |
544 lines · Rust