| 679 | RequireUser(user): RequireUser, | 679 | RequireUser(user): RequireUser, |
| 680 | jar: CookieJar, | 680 | jar: CookieJar, |
| 681 | uri: Uri, | 681 | uri: Uri, |
| 682 | ) -> Response { | 682 | ) -> AppResult<Response> { |
| | 683 | let tokens = state.store.tokens_by_user(&user.id).await?; |
| 683 | let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); | 684 | let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); |
| 684 | let body = settings_body(&user, &chrome.csrf, None); | 685 | let body = settings_body(&user, &chrome.csrf, &tokens, None); |
| 685 | (jar, page(&chrome, "Settings", body)).into_response() | 686 | Ok((jar, page(&chrome, "Settings", body)).into_response()) |
| 686 | } | 687 | } |
| 687 | | 688 | |
| 688 | | 689 | |
| 762 | (!t.is_empty()).then(|| t.to_string()) | 763 | (!t.is_empty()).then(|| t.to_string()) |
| 763 | } | 764 | } |
| 764 | | 765 | |
| 765 | | 766 | |
| 766 | fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | 767 | #[derive(Debug, Deserialize)] |
| | 768 | pub struct AccountFieldForm { |
| | 769 | |
| | 770 | #[serde(default)] |
| | 771 | username: String, |
| | 772 | |
| | 773 | #[serde(default)] |
| | 774 | email: String, |
| | 775 | |
| | 776 | #[serde(rename = "_csrf")] |
| | 777 | csrf: String, |
| | 778 | } |
| | 779 | |
| | 780 | |
| | 781 | pub async fn account_username( |
| | 782 | State(state): State<AppState>, |
| | 783 | RequireUser(user): RequireUser, |
| | 784 | jar: CookieJar, |
| | 785 | headers: HeaderMap, |
| | 786 | Form(form): Form<AccountFieldForm>, |
| | 787 | ) -> Response { |
| | 788 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 789 | return AppError::Forbidden.into_response(); |
| | 790 | } |
| | 791 | match state |
| | 792 | .store |
| | 793 | .update_username(&user.id, form.username.trim()) |
| | 794 | .await |
| | 795 | { |
| | 796 | Ok(_) => Redirect::to("/settings").into_response(), |
| | 797 | Err(store::StoreError::Conflict { .. }) => { |
| | 798 | AppError::BadRequest("That username is already taken.".to_string()).into_response() |
| | 799 | } |
| | 800 | Err(store::StoreError::Name(_)) => { |
| | 801 | AppError::BadRequest("Invalid username.".to_string()).into_response() |
| | 802 | } |
| | 803 | Err(err) => AppError::from(err).into_response(), |
| | 804 | } |
| | 805 | } |
| | 806 | |
| | 807 | |
| | 808 | pub async fn account_email( |
| | 809 | State(state): State<AppState>, |
| | 810 | RequireUser(user): RequireUser, |
| | 811 | jar: CookieJar, |
| | 812 | headers: HeaderMap, |
| | 813 | Form(form): Form<AccountFieldForm>, |
| | 814 | ) -> Response { |
| | 815 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 816 | return AppError::Forbidden.into_response(); |
| | 817 | } |
| | 818 | let email = form.email.trim(); |
| | 819 | if !email.contains('@') { |
| | 820 | return AppError::BadRequest("Enter a valid email address.".to_string()).into_response(); |
| | 821 | } |
| | 822 | match state.store.update_email(&user.id, email).await { |
| | 823 | Ok(_) => Redirect::to("/settings").into_response(), |
| | 824 | Err(store::StoreError::Conflict { .. }) => { |
| | 825 | AppError::BadRequest("That email is already in use.".to_string()).into_response() |
| | 826 | } |
| | 827 | Err(err) => AppError::from(err).into_response(), |
| | 828 | } |
| | 829 | } |
| | 830 | |
| | 831 | |
| | 832 | #[derive(Debug, Deserialize)] |
| | 833 | pub struct PasswordForm { |
| | 834 | |
| | 835 | #[serde(default)] |
| | 836 | current: String, |
| | 837 | |
| | 838 | #[serde(default)] |
| | 839 | new_password: String, |
| | 840 | |
| | 841 | #[serde(default)] |
| | 842 | confirm: String, |
| | 843 | |
| | 844 | #[serde(rename = "_csrf")] |
| | 845 | csrf: String, |
| | 846 | } |
| | 847 | |
| | 848 | |
| | 849 | |
| | 850 | pub async fn account_password( |
| | 851 | State(state): State<AppState>, |
| | 852 | RequireUser(user): RequireUser, |
| | 853 | jar: CookieJar, |
| | 854 | headers: HeaderMap, |
| | 855 | Form(form): Form<PasswordForm>, |
| | 856 | ) -> Response { |
| | 857 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 858 | return AppError::Forbidden.into_response(); |
| | 859 | } |
| | 860 | if !auth::verify_password(&form.current, user.password_hash.as_deref()) { |
| | 861 | return AppError::BadRequest("Current password is incorrect.".to_string()).into_response(); |
| | 862 | } |
| | 863 | if form.new_password != form.confirm { |
| | 864 | return AppError::BadRequest("New passwords do not match.".to_string()).into_response(); |
| | 865 | } |
| | 866 | if form.new_password.len() < 8 { |
| | 867 | return AppError::BadRequest("New password must be at least 8 characters.".to_string()) |
| | 868 | .into_response(); |
| | 869 | } |
| | 870 | let result = async { |
| | 871 | let params = auth::HashParams { |
| | 872 | m_cost: state.config.auth.argon2_m_cost, |
| | 873 | t_cost: state.config.auth.argon2_t_cost, |
| | 874 | p_cost: state.config.auth.argon2_p_cost, |
| | 875 | }; |
| | 876 | let hash = auth::hash_password(&form.new_password, params).map_err(AppError::internal)?; |
| | 877 | |
| | 878 | state.store.set_password(&user.id, Some(&hash)).await?; |
| | 879 | open_session(&state, &jar, &user, &headers).await |
| | 880 | } |
| | 881 | .await; |
| | 882 | match result { |
| | 883 | Ok(jar) => (jar, Redirect::to("/settings")).into_response(), |
| | 884 | Err(err) => err.into_response(), |
| | 885 | } |
| | 886 | } |
| | 887 | |
| | 888 | |
| | 889 | #[derive(Debug, Deserialize)] |
| | 890 | pub struct TokenCreateForm { |
| | 891 | |
| | 892 | #[serde(default)] |
| | 893 | name: String, |
| | 894 | |
| | 895 | #[serde(default)] |
| | 896 | repo_read: Option<String>, |
| | 897 | |
| | 898 | #[serde(default)] |
| | 899 | repo_write: Option<String>, |
| | 900 | |
| | 901 | #[serde(default)] |
| | 902 | admin: Option<String>, |
| | 903 | |
| | 904 | #[serde(rename = "_csrf")] |
| | 905 | csrf: String, |
| | 906 | } |
| | 907 | |
| | 908 | |
| | 909 | pub async fn token_create( |
| | 910 | State(state): State<AppState>, |
| | 911 | RequireUser(user): RequireUser, |
| | 912 | jar: CookieJar, |
| | 913 | headers: HeaderMap, |
| | 914 | Form(form): Form<TokenCreateForm>, |
| | 915 | ) -> Response { |
| | 916 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 917 | return AppError::Forbidden.into_response(); |
| | 918 | } |
| | 919 | let name = form.name.trim(); |
| | 920 | if name.is_empty() { |
| | 921 | return AppError::BadRequest("Token name is required.".to_string()).into_response(); |
| | 922 | } |
| | 923 | let mut scopes: Vec<&str> = Vec::new(); |
| | 924 | if form.repo_read.is_some() { |
| | 925 | scopes.push("repo:read"); |
| | 926 | } |
| | 927 | if form.repo_write.is_some() { |
| | 928 | scopes.push("repo:write"); |
| | 929 | } |
| | 930 | if form.admin.is_some() { |
| | 931 | scopes.push("admin"); |
| | 932 | } |
| | 933 | if scopes.is_empty() { |
| | 934 | return AppError::BadRequest("Select at least one scope.".to_string()).into_response(); |
| | 935 | } |
| | 936 | let scope_str = scopes.join(","); |
| | 937 | |
| | 938 | let result = async { |
| | 939 | let parsed = auth::parse_scopes(&scope_str).map_err(AppError::internal)?; |
| | 940 | let canonical = auth::format_scopes(&parsed); |
| | 941 | let token = state |
| | 942 | .store |
| | 943 | .create_token(store::NewToken { |
| | 944 | user_id: user.id.clone(), |
| | 945 | name: name.to_string(), |
| | 946 | scopes: canonical.clone(), |
| | 947 | expires_at: None, |
| | 948 | }) |
| | 949 | .await?; |
| | 950 | |
| | 951 | |
| | 952 | let now = now_ms().div_euclid(1000); |
| | 953 | let claims = auth::Claims { |
| | 954 | sub: user.id.clone(), |
| | 955 | jti: token.id.clone(), |
| | 956 | scopes: canonical, |
| | 957 | iat: now, |
| | 958 | exp: now.saturating_add(10 * 365 * 86_400), |
| | 959 | }; |
| | 960 | let jwt = auth::mint_jwt(&state.secret, &claims).map_err(AppError::internal)?; |
| | 961 | Ok::<String, AppError>(jwt) |
| | 962 | } |
| | 963 | .await; |
| | 964 | |
| | 965 | match result { |
| | 966 | Ok(jwt) => { |
| | 967 | let tokens = state |
| | 968 | .store |
| | 969 | .tokens_by_user(&user.id) |
| | 970 | .await |
| | 971 | .unwrap_or_default(); |
| | 972 | let (jar, chrome) = |
| | 973 | build_chrome(&state, jar, Some(user.clone()), "/settings".to_string()); |
| | 974 | let body = settings_body(&user, &chrome.csrf, &tokens, Some(&jwt)); |
| | 975 | (jar, page(&chrome, "Settings", body)).into_response() |
| | 976 | } |
| | 977 | Err(err) => err.into_response(), |
| | 978 | } |
| | 979 | } |
| | 980 | |
| | 981 | |
| | 982 | #[derive(Debug, Deserialize)] |
| | 983 | pub struct TokenRevokeForm { |
| | 984 | |
| | 985 | #[serde(default)] |
| | 986 | id: String, |
| | 987 | |
| | 988 | #[serde(rename = "_csrf")] |
| | 989 | csrf: String, |
| | 990 | } |
| | 991 | |
| | 992 | |
| | 993 | pub async fn token_revoke( |
| | 994 | State(state): State<AppState>, |
| | 995 | RequireUser(user): RequireUser, |
| | 996 | jar: CookieJar, |
| | 997 | headers: HeaderMap, |
| | 998 | Form(form): Form<TokenRevokeForm>, |
| | 999 | ) -> Response { |
| | 1000 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 1001 | return AppError::Forbidden.into_response(); |
| | 1002 | } |
| | 1003 | |
| | 1004 | let owned = state |
| | 1005 | .store |
| | 1006 | .token_by_id(&form.id) |
| | 1007 | .await |
| | 1008 | .ok() |
| | 1009 | .flatten() |
| | 1010 | .is_some_and(|t| t.user_id == user.id); |
| | 1011 | if owned { |
| | 1012 | let _ = state.store.revoke_token(&form.id).await; |
| | 1013 | } |
| | 1014 | Redirect::to("/settings").into_response() |
| | 1015 | } |
| | 1016 | |
| | 1017 | |
| | 1018 | fn settings_body( |
| | 1019 | user: &User, |
| | 1020 | csrf: &str, |
| | 1021 | tokens: &[model::ApiToken], |
| | 1022 | new_token: Option<&str>, |
| | 1023 | ) -> Markup { |
| 767 | let val = |v: &Option<String>| v.clone().unwrap_or_default(); | 1024 | let val = |v: &Option<String>| v.clone().unwrap_or_default(); |
| 768 | html! { | 1025 | html! { |
| 769 | h1 { "Settings" } | 1026 | h1 { "Settings" } |
| 770 | @if let Some(msg) = notice { | | |
| 771 | div class="notice notice-success" { (msg) } | | |
| 772 | } | | |
| 773 | section class="listing" { | 1027 | section class="listing" { |
| 774 | h2 { "Avatar" } | 1028 | h2 { "Avatar" } |
| 775 | div class="card avatar-settings" { | 1029 | div class="card avatar-settings" { |
| 816 | section class="listing" { | 1070 | section class="listing" { |
| 817 | h2 { "Account" } | 1071 | h2 { "Account" } |
| 818 | div class="card" { | 1072 | div class="card" { |
| 819 | table class="list" { | 1073 | form method="post" action="/settings/username" { |
| 820 | tr { th { "Username" } td { (user.username) } } | 1074 | input type="hidden" name="_csrf" value=(csrf); |
| 821 | tr { th { "Email" } td { (user.email) } } | 1075 | label for="username" { "Username" } |
| 822 | tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } } | 1076 | input type="text" id="username" name="username" value=(user.username) required; |
| | 1077 | button class="btn" type="submit" { "Change username" } |
| | 1078 | } |
| | 1079 | form method="post" action="/settings/email" { |
| | 1080 | input type="hidden" name="_csrf" value=(csrf); |
| | 1081 | label for="email" { "Email" } |
| | 1082 | input type="email" id="email" name="email" value=(user.email) required; |
| | 1083 | button class="btn" type="submit" { "Change email" } |
| | 1084 | } |
| | 1085 | p class="muted field-hint" { |
| | 1086 | "Administrator: " (if user.is_admin { "yes" } else { "no" }) |
| | 1087 | } |
| | 1088 | } |
| | 1089 | } |
| | 1090 | section class="listing" { |
| | 1091 | h2 { "Password" } |
| | 1092 | div class="card" { |
| | 1093 | form method="post" action="/settings/password" { |
| | 1094 | input type="hidden" name="_csrf" value=(csrf); |
| | 1095 | label for="current" { "Current password" } |
| | 1096 | input type="password" id="current" name="current" |
| | 1097 | autocomplete="current-password" required; |
| | 1098 | label for="new_password" { "New password" } |
| | 1099 | input type="password" id="new_password" name="new_password" |
| | 1100 | autocomplete="new-password" required minlength="8"; |
| | 1101 | label for="confirm" { "Confirm new password" } |
| | 1102 | input type="password" id="confirm" name="confirm" |
| | 1103 | autocomplete="new-password" required; |
| | 1104 | button class="btn btn-primary" type="submit" { "Change password" } |
| | 1105 | } |
| | 1106 | } |
| | 1107 | } |
| | 1108 | (tokens_section(csrf, tokens, new_token)) |
| | 1109 | } |
| | 1110 | } |
| | 1111 | |
| | 1112 | |
| | 1113 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { |
| | 1114 | html! { |
| | 1115 | section class="listing" { |
| | 1116 | h2 { "API tokens" } |
| | 1117 | div class="card" { |
| | 1118 | @if let Some(secret) = new_token { |
| | 1119 | div class="notice notice-success" { |
| | 1120 | p { strong { "New token created." } " Copy it now — it is not shown again." } |
| | 1121 | pre class="token-secret" { code { (secret) } } |
| | 1122 | } |
| | 1123 | } |
| | 1124 | @if tokens.is_empty() { |
| | 1125 | p class="muted" { "No API tokens yet." } |
| | 1126 | } @else { |
| | 1127 | ul class="entry-list token-list" { |
| | 1128 | @for t in tokens { |
| | 1129 | li class="entry-row" { |
| | 1130 | div class="entry-row-body" { |
| | 1131 | span class="entry-row-title" { (t.name) } |
| | 1132 | div class="entry-row-meta muted" { |
| | 1133 | span { (t.scopes) } |
| | 1134 | @if t.revoked_at.is_some() { span class="badge badge-private" { "revoked" } } |
| | 1135 | } |
| | 1136 | } |
| | 1137 | div class="entry-row-actions" { |
| | 1138 | @if t.revoked_at.is_none() { |
| | 1139 | form method="post" action="/settings/tokens/revoke" { |
| | 1140 | input type="hidden" name="_csrf" value=(csrf); |
| | 1141 | input type="hidden" name="id" value=(t.id); |
| | 1142 | button class="btn btn-danger" type="submit" { "Revoke" } |
| | 1143 | } |
| | 1144 | } |
| | 1145 | } |
| | 1146 | } |
| | 1147 | } |
| | 1148 | } |
| | 1149 | } |
| | 1150 | form class="token-add" method="post" action="/settings/tokens" { |
| | 1151 | input type="hidden" name="_csrf" value=(csrf); |
| | 1152 | label for="token_name" { "New token" } |
| | 1153 | input type="text" id="token_name" name="name" placeholder="Token name" required; |
| | 1154 | fieldset class="token-scopes" { |
| | 1155 | legend { "Scopes" } |
| | 1156 | label class="checkbox" { input type="checkbox" name="repo_read" value="1" checked; " repo:read" } |
| | 1157 | label class="checkbox" { input type="checkbox" name="repo_write" value="1"; " repo:write" } |
| | 1158 | label class="checkbox" { input type="checkbox" name="admin" value="1"; " admin" } |
| | 1159 | } |
| | 1160 | button class="btn btn-primary" type="submit" { "Create token" } |
| 823 | } | 1161 | } |
| 824 | } | 1162 | } |
| 825 | } | 1163 | } |