Skip to content

Commit 96006b1

Browse files
Add Rust account edit UI
1 parent 26c367d commit 96006b1

5 files changed

Lines changed: 326 additions & 30 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Fuer NixOS und andere lokale Linux-Setups ist der bevorzugte lokale Pfad jetzt R
1010
./start.sh
1111
```
1212

13-
Danach ist die Rust-Weboberflaeche unter `http://127.0.0.1:9000/login/` erreichbar. Der Rust-Demo-Login lautet `admin / Admin123!`; die Rust-Userverwaltung liegt unter `http://127.0.0.1:9000/admin/users/`.
13+
Danach ist die Rust-Weboberflaeche unter `http://127.0.0.1:9000/login/` erreichbar. Der Rust-Demo-Login lautet `admin / Admin123!`; die Rust-Userverwaltung fuer Anlage, Bearbeitung, Rollenwechsel und Passwortreset liegt unter `http://127.0.0.1:9000/admin/users/`.
1414

1515
Explizit ohne Wrapper:
1616

@@ -54,7 +54,7 @@ Der lokale Wrapper startet denselben Rust-only-Pfad und initialisiert vorher die
5454
./start.sh
5555
```
5656

57-
Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Danach ist `/admin/users/` fuer User-Liste und User-Anlage ueber Rust verfuegbar.
57+
Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Danach ist `/admin/users/` fuer User-Liste, User-Anlage, Bearbeitung, Rollenwechsel und Passwortreset ueber Rust verfuegbar.
5858

5959
## Neu in V23.5
6060

docs/RUST_CUTOVER_STATUS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Der lokale Wrapper initialisiert die Rust-Datenbank und startet den Rust-Axum-Se
4040
./start.sh
4141
```
4242

43-
Rust-Demo-Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Die Rust-Userverwaltung liegt danach unter `http://127.0.0.1:9000/admin/users/`.
43+
Rust-Demo-Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Die Rust-Userverwaltung liegt danach unter `http://127.0.0.1:9000/admin/users/` und erlaubt User-Anlage, User-Bearbeitung, Rollenwechsel und Passwortreset.
4444

4545
Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
4646

@@ -55,7 +55,7 @@ Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
5555
- Rust-DB-Admin-CLI mit `migrate`, `seed-demo` und `init-demo` fuer Rust-eigenen SQLite/PostgreSQL-Bootstrap der operativen Kern-Tabellen inklusive Product-Security sowie vollstaendigem Katalog-/Requirement-Seed.
5656
- Rust-Session-Schicht mit `iscy_auth_session`, `/api/v1/auth/sessions`, `/api/v1/auth/session`, Logout, Cookie/Bearer-Aufloesung, Django-kompatibler `pbkdf2_sha256`-Passwortpruefung und Web-Kontext ohne Query-Parameter.
5757
- Rust-RBAC-Grundlage mit `accounts_role`, `accounts_userrole`, Session-Rollencodes, Header-Rollen-Fallback und Schreibschutz fuer migrierte Write-Endpunkte.
58-
- Rust-Account-Administration mit `/api/v1/accounts/users`, `/api/v1/accounts/users/{user_id}`, `/api/v1/accounts/roles` sowie Webroute `/admin/users/` fuer User-Liste, User-Anlage, Passwort-Hashing und tenant-scoped Rollenzuweisung.
58+
- Rust-Account-Administration mit `/api/v1/accounts/users`, `/api/v1/accounts/users/{user_id}`, `/api/v1/accounts/roles` sowie Webroute `/admin/users/` fuer User-Liste, User-Anlage, User-Bearbeitung, Passwort-Hashing/-Reset und tenant-scoped Rollenzuweisung.
5959
- Rust-first CI mit Rust-Tests, Rust-DB-/HTTP-Smoke und Nix-Rust-App-Smoke.
6060
- `start.sh` startet lokal Rust-only statt Django-runserver.
6161
- Roadmap Liste, Detail, Kanban, Task-Updates und Exportdaten.
@@ -67,7 +67,7 @@ Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
6767
## Blocker vor Python-Loeschung
6868

6969
1. **Weboberflaeche:** Rust liefert fuer `/dashboard/`, `/risks/`, `/evidence/`, `/reports/`, `/roadmap/`, `/assets/` und `/processes/` bereits echte serverseitige Seiten. Die restlichen Views, Detail-/Form-Flows und Exporte liegen noch in Django-Templates und Django-Views.
70-
2. **Auth, Sessions und Admin:** Rust-Sessions, Passwort-Login, Rollen-/Schreibrechte und erste Account-Administration sind vorhanden. Feingranulare Berechtigungsoberflaechen, Gruppen-/Permission-Modelle und vollstaendige Admin-Paritaet sind noch nicht komplett ersetzt.
70+
2. **Auth, Sessions und Admin:** Rust-Sessions, Passwort-Login, Rollen-/Schreibrechte und Account-Administration fuer User/Rollenwechsel sind vorhanden. Feingranulare Gruppen-/Permission-Modelle und vollstaendige Django-Admin-Paritaet sind noch nicht komplett ersetzt.
7171
3. **Migrations und Seeds:** Ein Rust-eigener Bootstrap fuer operative Kern-Tabellen inklusive Product-Security, Catalog und Requirements ist vorhanden. Einzelne historische Django-Schema-Details ausserhalb dieser Cutover-Slices sind noch nicht vollstaendig abgeloest.
7272
4. **Formulare und Uploads:** Validierung, Form-Flows, Evidence-Dateiuploads sowie CSV/XLSX-Import-Mapping sind noch teilweise Django-orchestriert.
7373
5. **Python-Dateien im Repo:** CI und lokaler Start sind Rust-first. Python/Django-Dateien bleiben noch als Legacy-Kompatibilitaet und muessen nach Abschluss von Auth/Web/Form-Flows gezielt entfernt werden.
@@ -76,7 +76,7 @@ Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
7676

7777
Der naechste Abschlussblock ist nicht mehr ein weiterer einzelner API-Endpunkt, sondern die Web- und Betriebsablösung:
7878

79-
1. Berechtigungsoberflaechen, Detail-/Edit-Formulare und letzte Admin-Paritaet in Rust finalisieren.
79+
1. Feingranulare Gruppen-/Permission-Modelle und letzte Admin-Paritaet in Rust finalisieren.
8080
2. Django-Templates durch Rust-Web oder ein separates Frontend auf Rust-API ersetzen.
8181
3. Upload-/Import-Dateifluss ohne Django bereitstellen.
8282
4. Python-Dateien, Requirements, Django-Settings und Django-Startpfade entfernen.

rust/iscy-backend/src/account_store.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,14 @@ impl AccountStore {
258258
.and_then(|roles| roles.first().cloned())
259259
.or_else(|| payload.role.as_deref().map(normalize_role))
260260
.unwrap_or(existing.role);
261-
let username = optional_text(payload.username.as_deref()).unwrap_or(existing.username);
262-
let first_name =
263-
optional_text(payload.first_name.as_deref()).unwrap_or(existing.first_name);
264-
let last_name = optional_text(payload.last_name.as_deref()).unwrap_or(existing.last_name);
265-
let email = optional_text(payload.email.as_deref()).unwrap_or(existing.email);
266-
let job_title = optional_text(payload.job_title.as_deref()).unwrap_or(existing.job_title);
261+
let username = match payload.username.as_deref() {
262+
Some(username) => required_text(Some(username), "username")?,
263+
None => existing.username,
264+
};
265+
let first_name = text_update(payload.first_name.as_deref()).unwrap_or(existing.first_name);
266+
let last_name = text_update(payload.last_name.as_deref()).unwrap_or(existing.last_name);
267+
let email = text_update(payload.email.as_deref()).unwrap_or(existing.email);
268+
let job_title = text_update(payload.job_title.as_deref()).unwrap_or(existing.job_title);
267269
let is_staff = payload.is_staff.unwrap_or(existing.is_staff);
268270
let is_superuser = payload.is_superuser.unwrap_or(existing.is_superuser);
269271
let is_active = payload.is_active.unwrap_or(existing.is_active);
@@ -659,6 +661,10 @@ fn optional_text(value: Option<&str>) -> Option<String> {
659661
.map(ToString::to_string)
660662
}
661663

664+
fn text_update(value: Option<&str>) -> Option<String> {
665+
value.map(str::trim).map(ToString::to_string)
666+
}
667+
662668
fn role_codes_for_create(role: Option<&str>, roles: Option<&[String]>) -> Vec<String> {
663669
let mut role_codes = roles.map(role_codes_from_list).unwrap_or_default();
664670
if role_codes.is_empty() {

rust/iscy-backend/src/lib.rs

Lines changed: 174 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,20 @@ struct WebAccountUserCreateForm {
320320
is_superuser: Option<String>,
321321
}
322322

323+
#[derive(Debug, Deserialize)]
324+
struct WebAccountUserUpdateForm {
325+
username: String,
326+
password: Option<String>,
327+
first_name: Option<String>,
328+
last_name: Option<String>,
329+
email: Option<String>,
330+
role: Option<String>,
331+
job_title: Option<String>,
332+
is_active: Option<String>,
333+
is_staff: Option<String>,
334+
is_superuser: Option<String>,
335+
}
336+
323337
#[derive(Debug, Serialize)]
324338
pub struct TenantProfileResponse {
325339
pub api_version: &'static str,
@@ -3664,23 +3678,7 @@ async fn web_admin_users(
36643678
Ok(roles) => roles,
36653679
Err(err) => return web_error_page("Users", "/admin/users/", &context, &err.to_string()),
36663680
};
3667-
let role_options = roles
3668-
.iter()
3669-
.map(|role| {
3670-
let selected = if role.code == "CONTRIBUTOR" {
3671-
" selected"
3672-
} else {
3673-
""
3674-
};
3675-
format!(
3676-
r#"<option value="{}"{}>{}</option>"#,
3677-
html_escape(&role.code),
3678-
selected,
3679-
html_escape(&role.label),
3680-
)
3681-
})
3682-
.collect::<Vec<_>>()
3683-
.join("");
3681+
let create_role_options = role_options_for(&roles, "CONTRIBUTOR");
36843682
let rows = users
36853683
.iter()
36863684
.map(|user| {
@@ -3697,6 +3695,50 @@ async fn web_admin_users(
36973695
})
36983696
.collect::<Vec<_>>()
36993697
.join("");
3698+
let edit_forms = users
3699+
.iter()
3700+
.map(|user| {
3701+
let selected_role = user
3702+
.roles
3703+
.first()
3704+
.map(String::as_str)
3705+
.unwrap_or(user.role.as_str());
3706+
format!(
3707+
r#"
3708+
<form class="user-editor" method="post" action="/admin/users/{}">
3709+
<h3>{}</h3>
3710+
<div class="form-grid">
3711+
<label>Benutzername<input name="username" type="text" autocomplete="username" required value="{}"></label>
3712+
<label>Neues Passwort<input name="password" type="password" autocomplete="new-password"></label>
3713+
<label>Vorname<input name="first_name" type="text" autocomplete="given-name" value="{}"></label>
3714+
<label>Nachname<input name="last_name" type="text" autocomplete="family-name" value="{}"></label>
3715+
<label>E-Mail<input name="email" type="email" autocomplete="email" value="{}"></label>
3716+
<label>Jobtitel<input name="job_title" type="text" value="{}"></label>
3717+
<label>Rolle<select name="role">{}</select></label>
3718+
</div>
3719+
<div class="toggle-row">
3720+
<label class="checkbox-row"><input name="is_active" type="checkbox" value="1"{}> Aktiv</label>
3721+
<label class="checkbox-row"><input name="is_staff" type="checkbox" value="1"{}> Staff</label>
3722+
<label class="checkbox-row"><input name="is_superuser" type="checkbox" value="1"{}> Superuser</label>
3723+
</div>
3724+
<button type="submit">Aenderungen speichern</button>
3725+
</form>
3726+
"#,
3727+
user.id,
3728+
html_escape(&user.display_name),
3729+
html_escape(&user.username),
3730+
html_escape(&user.first_name),
3731+
html_escape(&user.last_name),
3732+
html_escape(&user.email),
3733+
html_escape(&user.job_title),
3734+
role_options_for(&roles, selected_role),
3735+
checked_attr(user.is_active),
3736+
checked_attr(user.is_staff),
3737+
checked_attr(user.is_superuser),
3738+
)
3739+
})
3740+
.collect::<Vec<_>>()
3741+
.join("");
37003742
let body = format!(
37013743
r#"
37023744
<section class="hero compact"><h1>Users</h1><p>{} Accounts fuer Tenant {}</p></section>
@@ -3723,6 +3765,10 @@ async fn web_admin_users(
37233765
<button type="submit">User anlegen</button>
37243766
</form>
37253767
</article>
3768+
<article class="panel wide">
3769+
<h2>User bearbeiten</h2>
3770+
<div class="editor-stack">{}</div>
3771+
</article>
37263772
</section>
37273773
"#,
37283774
users.len(),
@@ -3732,10 +3778,16 @@ async fn web_admin_users(
37323778
} else {
37333779
rows
37343780
},
3735-
role_options,
3781+
create_role_options,
3782+
if edit_forms.is_empty() {
3783+
"<p>Keine Accounts vorhanden.</p>".to_string()
3784+
} else {
3785+
edit_forms
3786+
},
37363787
);
37373788
web_page("Users", "/admin/users/", Some(&context), &body)
37383789
}
3790+
37393791
async fn web_admin_users_submit(
37403792
State(state): State<AppState>,
37413793
headers: HeaderMap,
@@ -3791,6 +3843,75 @@ async fn web_admin_users_submit(
37913843
}
37923844
}
37933845
}
3846+
3847+
async fn web_admin_user_update(
3848+
Path(user_id): Path<i64>,
3849+
State(state): State<AppState>,
3850+
headers: HeaderMap,
3851+
Form(form): Form<WebAccountUserUpdateForm>,
3852+
) -> Response {
3853+
let auth_context = match authenticated_tenant_context(&state, &headers).await {
3854+
Ok(context) => context,
3855+
Err(_) => return web_missing_context("Users", "/admin/users/").into_response(),
3856+
};
3857+
let context = WebContext {
3858+
tenant_id: auth_context.tenant_id,
3859+
user_id: auth_context.user_id,
3860+
user_email: auth_context.user_email.clone(),
3861+
};
3862+
if admin_permission_error(&auth_context).is_some() {
3863+
return web_error_page(
3864+
"Users",
3865+
"/admin/users/",
3866+
&context,
3867+
"Diese Rust-Webroute benoetigt eine Admin-Rolle.",
3868+
)
3869+
.into_response();
3870+
}
3871+
let Some(store) = state.account_store else {
3872+
return web_store_missing("Users", "/admin/users/", &context, "Account").into_response();
3873+
};
3874+
let role = form
3875+
.role
3876+
.as_ref()
3877+
.map(|value| value.trim().to_string())
3878+
.filter(|value| !value.is_empty());
3879+
let roles = role.as_ref().map(|role| vec![role.clone()]);
3880+
let payload = account_store::AccountUserWriteRequest {
3881+
username: Some(form.username),
3882+
password: form.password,
3883+
first_name: form.first_name,
3884+
last_name: form.last_name,
3885+
email: form.email,
3886+
role,
3887+
roles,
3888+
job_title: form.job_title,
3889+
is_staff: Some(form.is_staff.is_some()),
3890+
is_superuser: Some(form.is_superuser.is_some()),
3891+
is_active: Some(form.is_active.is_some()),
3892+
};
3893+
match store
3894+
.update_user(
3895+
auth_context.tenant_id,
3896+
user_id,
3897+
auth_context.user_id,
3898+
payload,
3899+
)
3900+
.await
3901+
{
3902+
Ok(Some(_)) => Redirect::to("/admin/users/").into_response(),
3903+
Ok(None) => web_error_page(
3904+
"Users",
3905+
"/admin/users/",
3906+
&context,
3907+
"Account-User wurde nicht gefunden.",
3908+
)
3909+
.into_response(),
3910+
Err(err) => {
3911+
web_error_page("Users", "/admin/users/", &context, &err.to_string()).into_response()
3912+
}
3913+
}
3914+
}
37943915
async fn web_product_security(
37953916
State(state): State<AppState>,
37963917
headers: HeaderMap,
@@ -3971,6 +4092,7 @@ fn web_page(
39714092
.hero.compact {{ padding:16px 0 24px; }}
39724093
h1 {{ margin:0 0 8px; font-size:40px; line-height:1.1; }}
39734094
h2 {{ margin:0 0 10px; font-size:20px; }}
4095+
h3 {{ margin:0; font-size:17px; }}
39744096
p {{ margin:0 0 8px; color:var(--muted); }}
39754097
.grid {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(230px, 1fr)); gap:14px; }}
39764098
.metrics {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(160px, 1fr)); gap:12px; margin-bottom:16px; }}
@@ -3989,6 +4111,11 @@ fn web_page(
39894111
input, select {{ width:100%; padding:10px 12px; border:1px solid var(--line); border-radius:6px; font:inherit; background:#fff; }}
39904112
input[type="checkbox"] {{ width:auto; }}
39914113
.checkbox-row {{ display:flex; align-items:center; gap:8px; }}
4114+
.form-grid {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(190px, 1fr)); gap:12px; }}
4115+
.editor-stack {{ display:grid; gap:16px; }}
4116+
.user-editor {{ padding:14px 0; border-top:1px solid var(--line); }}
4117+
.user-editor:first-child {{ padding-top:0; border-top:0; }}
4118+
.toggle-row {{ display:flex; flex-wrap:wrap; gap:12px; }}
39924119
button {{ justify-self:start; border:0; border-radius:6px; background:var(--accent); color:#fff; padding:10px 14px; font-weight:700; cursor:pointer; }}
39934120
@media (max-width: 720px) {{ header {{ align-items:flex-start; flex-direction:column; }} h1 {{ font-size:32px; }} .context {{ justify-content:flex-start; }} }}
39944121
</style>
@@ -4034,6 +4161,34 @@ fn yes_no(value: bool) -> &'static str {
40344161
}
40354162
}
40364163

4164+
fn checked_attr(value: bool) -> &'static str {
4165+
if value {
4166+
" checked"
4167+
} else {
4168+
""
4169+
}
4170+
}
4171+
4172+
fn role_options_for(roles: &[account_store::AccountRole], selected_code: &str) -> String {
4173+
roles
4174+
.iter()
4175+
.map(|role| {
4176+
let selected = if role.code.eq_ignore_ascii_case(selected_code) {
4177+
" selected"
4178+
} else {
4179+
""
4180+
};
4181+
format!(
4182+
r#"<option value="{}"{}>{}</option>"#,
4183+
html_escape(&role.code),
4184+
selected,
4185+
html_escape(&role.label),
4186+
)
4187+
})
4188+
.collect::<Vec<_>>()
4189+
.join("")
4190+
}
4191+
40374192
fn web_link_card(title: &str, href: &str, subtitle: &str) -> String {
40384193
format!(
40394194
r#"<a class="panel card-link" href="{}"><h2>{}</h2><p>{}</p></a>"#,
@@ -4469,6 +4624,7 @@ pub fn app_router_with_state(state: AppState) -> Router {
44694624
"/admin/users/",
44704625
get(web_admin_users).post(web_admin_users_submit),
44714626
)
4627+
.route("/admin/users/{user_id}", post(web_admin_user_update))
44724628
.route("/product-security/", get(web_product_security))
44734629
.route("/cves/", get(web_cves))
44744630
.route("/api/v1/nvd/normalize", post(nvd_normalize))

0 commit comments

Comments
 (0)