Expand 678 hidden lines 679 679 RequireUser ( user) : RequireUser , 680 680 jar : CookieJar , 681 681 uri : Uri , 682 - ) -> Response { 682 + ) -> AppResult < Response > { 683 + let tokens = state. store. tokens_by_user ( & user. id) . await ?; 683 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 - ( jar, page ( & chrome, "Settings" , body) ) . into_response ( ) 685 + let body = settings_body ( & user, & chrome. csrf, & tokens, None ) ; 686 + Ok ( ( jar, page ( & chrome, "Settings" , body) ) . into_response ( ) ) 686 687 } 687 688 688 689 Expand 73 hidden lines 762 763 ( !t. is_empty ( ) ) . then ( || t. to_string ( ) ) 763 764 } 764 765 765 - 766 - fn settings_body ( user : & User , csrf : & str , notice : Option < & str > ) -> Markup { 766 + 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 1024 let val = |v : & Option < String > | v. clone ( ) . unwrap_or_default ( ) ; 768 1025 html ! { 769 1026 h1 { "Settings" } 770 - @if let Some ( msg) = notice { 771 - div class="notice notice-success" { ( msg) } 772 - } 773 1027 section class="listing" { 774 1028 h2 { "Avatar" } 775 1029 div class="card avatar-settings" { Expand 40 hidden lines 816 1070 section class="listing" { 817 1071 h2 { "Account" } 818 1072 div class="card" { 819 - table class="list" { 820 - tr { th { "Username" } td { ( user. username) } } 821 - tr { th { " Email " } td { ( user. email) } } 822 - tr { th { " Administrator " } td { ( if user. is_admin { "yes" } else { "no" } ) } } 1073 + form method="post" action="/settings/username" { 1074 + input type ="hidden" name="_csrf" value=( csrf) ; 1075 + label for ="username" { "Username" } 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 }