Skip to content

Commit d85872a

Browse files
committed
feat: added button to promote user to admin if curuser is a superuser
1 parent 7b15f24 commit d85872a

9 files changed

Lines changed: 129 additions & 69 deletions

File tree

backend/server/src/handler/organisation.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::models::auth::{AuthUser, OrganisationAdmin};
1313
use crate::models::campaign::{Campaign, NewCampaign};
1414
use crate::models::email_template::{EmailTemplate, NewEmailTemplate};
1515
use crate::models::error::ChaosError;
16-
use crate::models::organisation::{MemberToRemove, AdminUpdateList, NewOrganisation, Organisation, SlugCheck, MemberToInvite};
16+
use crate::models::organisation::{MemberToRemove, MemberRoleUpdate, AdminUpdateList, NewOrganisation, Organisation, SlugCheck, MemberToInvite};
1717
use crate::models::transaction::DBTransaction;
1818
use crate::service::auth::assert_is_super_user;
1919
use axum::extract::{Json, Path, State};
@@ -304,6 +304,19 @@ impl OrganisationHandler {
304304
Ok(AppMessage::OkMessage("Successfully updated organisation members"))
305305
}
306306

307+
/// Updates a single member's role (promote to Admin or demote to User). Superusers only.
308+
pub async fn update_member(
309+
mut transaction: DBTransaction<'_>,
310+
Path(id): Path<i64>,
311+
_super_user: SuperUser,
312+
Json(request_body): Json<MemberRoleUpdate>,
313+
) -> Result<impl IntoResponse, ChaosError> {
314+
Organisation::update_member_role(id, request_body.user_id, request_body.role, &mut transaction.tx).await?;
315+
316+
transaction.tx.commit().await?;
317+
Ok(AppMessage::OkMessage("Successfully updated member role"))
318+
}
319+
307320
/// Removes an admin from an organisation.
308321
///
309322
/// This handler allows super users to remove admins.

backend/server/src/handler/user.rs

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -175,35 +175,4 @@ impl UserHandler {
175175
let is_superuser = matches!(user.role, UserRole::SuperUser);
176176
Ok((StatusCode::OK, Json(serde_json::json!({ "is_superuser": is_superuser }))))
177177
}
178-
179-
/// Updates a user's role.
180-
///
181-
/// This endpoint is restricted to super users via the `SuperUser` extractor.
182-
/// It allows promoting or demoting users between `User` and `SuperUser`.
183-
///
184-
/// # Arguments
185-
///
186-
/// * `user` - The authenticated caller, must be a super user
187-
/// * `request_body.email` - Email of the user whose role is being changed
188-
/// * `request_body` - The new role for the user
189-
///
190-
/// # Returns
191-
///
192-
/// * `Result<impl IntoResponse, ChaosError>` - Success message or error
193-
pub async fn update_role(
194-
mut transaction: DBTransaction<'_>,
195-
_super_user: SuperUser,
196-
Json(request_body): Json<UserRoleUpdate>,
197-
) -> Result<impl IntoResponse, ChaosError> {
198-
// Look up the target user by email, then update their role.
199-
let maybe_user =
200-
User::find_by_email(request_body.email.to_lowercase(), &mut transaction.tx).await?;
201-
202-
let target_user = maybe_user.ok_or(ChaosError::BadRequest)?;
203-
204-
User::update_role(request_body.email, request_body.role, &mut transaction.tx).await?;
205-
206-
transaction.tx.commit().await?;
207-
Ok(AppMessage::OkMessage("Updated user role"))
208-
}
209178
}

backend/server/src/models/app.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::handler::user::UserHandler;
1313
use crate::models::email::{ChaosEmail, EmailCredentials};
1414
use crate::models::error::ChaosError;
1515
use crate::models::storage::Storage;
16-
use axum::routing::{delete, get, patch, post};
16+
use axum::routing::{delete, get, patch, post, put};
1717
use axum::{Json, Router};
1818
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
1919
use reqwest::Client as ReqwestClient;
@@ -205,7 +205,7 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
205205
.route("/api/v1/dev/super_admin_login", get(DevLoginHandler::dev_super_admin_login))
206206
.route("/api/v1/dev/org_admin_login", get(DevLoginHandler::dev_org_admin_login))
207207
.route("/api/v1/dev/user_login", get(DevLoginHandler::dev_user_login))
208-
.route("/api/v1/user", get(UserHandler::get)),
208+
.route("/api/v1/user", get(UserHandler::get))
209209
.route("/api/v1/user/is_superuser", get(UserHandler::is_superuser))
210210
.route("/api/v1/user/name", patch(UserHandler::update_name))
211211
.route("/api/v1/user/pronouns", patch(UserHandler::update_pronouns))
@@ -264,6 +264,10 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
264264
"/api/v1/organisation/:organisation_id/user",
265265
post(OrganisationHandler::invite_user).delete(OrganisationHandler::remove_user),
266266
)
267+
.route(
268+
"/api/v1/organisation/:organisation_id/member",
269+
put(OrganisationHandler::update_member),
270+
)
267271
.route(
268272
"/api/v1/organisation/:organisation_id/admins",
269273
get(OrganisationHandler::get_admins)
@@ -277,6 +281,10 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
277281
"/api/v1/organisation/:organisation_id/users",
278282
get(OrganisationHandler::get_users)
279283
)
284+
.route(
285+
"/api/v1/organisation/:organisation_id/users",
286+
put(OrganisationHandler::get_users)
287+
)
280288
// Campaign Rating Categories
281289
.route(
282290
"/api/v1/campaign/:campaign_id/rating_category",

backend/server/src/models/organisation.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ pub struct MemberToRemove {
142142
pub user_id: i64,
143143
}
144144

145+
/// Data structure for updating a single member's role (promote or demote).
146+
#[derive(Deserialize)]
147+
pub struct MemberRoleUpdate {
148+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
149+
pub user_id: i64,
150+
pub role: OrganisationRole,
151+
}
152+
145153
#[derive(Deserialize)]
146154
pub struct MemberToInvite {
147155
pub email: String,
@@ -625,6 +633,16 @@ impl Organisation {
625633
organisation_id: i64,
626634
admin_to_remove: i64,
627635
transaction: &mut Transaction<'_, Postgres>,
636+
) -> Result<(), ChaosError> {
637+
Self::update_member_role(organisation_id, admin_to_remove, OrganisationRole::User, transaction).await
638+
}
639+
640+
/// Updates a single member's role (promote to Admin or demote to User). The user must already be in the organisation.
641+
pub async fn update_member_role(
642+
organisation_id: i64,
643+
user_id: i64,
644+
role: OrganisationRole,
645+
transaction: &mut Transaction<'_, Postgres>,
628646
) -> Result<(), ChaosError> {
629647
let _ = sqlx::query!(
630648
"SELECT id FROM organisations WHERE id = $1",
@@ -637,9 +655,9 @@ impl Organisation {
637655
"
638656
UPDATE organisation_members SET role = $3 WHERE user_id = $1 AND organisation_id = $2
639657
",
640-
admin_to_remove,
658+
user_id,
641659
organisation_id,
642-
OrganisationRole::User as OrganisationRole
660+
role as OrganisationRole
643661
)
644662
.execute(transaction.deref_mut())
645663
.await?;

backend/server/src/models/user.rs

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -304,31 +304,6 @@ impl User {
304304
Ok(())
305305
}
306306

307-
/// Updates a user's role.
308-
///
309-
/// This is intended for administrative use only, and should be called from
310-
/// handlers that have already verified the caller is a super user.
311-
pub async fn update_role(
312-
email: String,
313-
role: UserRole,
314-
transaction: &mut Transaction<'_, Postgres>,
315-
) -> Result<(), ChaosError> {
316-
let _ = sqlx::query!(
317-
r#"
318-
UPDATE users
319-
SET role = $1::user_role
320-
WHERE email = $2
321-
RETURNING email
322-
"#,
323-
role as UserRole,
324-
email
325-
)
326-
.fetch_one(transaction.deref_mut())
327-
.await?;
328-
329-
Ok(())
330-
}
331-
332307

333308
/// Creates a User, This should only used for database seeding
334309
pub async fn create_user(

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/columns.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,41 @@ import { Button } from "@/components/ui/button";
2121
import { useState } from "react";
2222
import { AddMemberDialog } from "./members";
2323

24-
export function getColumns(handleDeleteMember: (memberId: string) => Promise<void>, dict: any): ColumnDef<Member>[] {
25-
return [
24+
function UpdateRoleButton({
25+
member,
26+
onUpdate,
27+
}: {
28+
member: Member;
29+
onUpdate: (memberId: string, newRole: "Admin" | "User") => Promise<void>;
30+
}) {
31+
const [loading, setLoading] = useState(false);
32+
const isAdmin = member.role === "Admin";
33+
34+
const handleClick = async () => {
35+
setLoading(true);
36+
await onUpdate(member.id, isAdmin ? "User" : "Admin");
37+
setLoading(false);
38+
};
39+
40+
return (
41+
<Button
42+
variant="outline"
43+
size="sm"
44+
disabled={loading}
45+
onClick={handleClick}
46+
>
47+
{loading ? "..." : isAdmin ? "Remove admin" : "Make admin"}
48+
</Button>
49+
);
50+
}
51+
52+
export function getColumns(
53+
handleDeleteMember: (memberId: string) => Promise<void>,
54+
handleUpdateMemberRole: (memberId: string, newRole: "Admin" | "User") => Promise<void>,
55+
dict: any,
56+
isSuperuser = false
57+
): ColumnDef<Member>[] {
58+
const columns: ColumnDef<Member>[] = [
2659
{
2760
header: dict.common.name,
2861
accessorKey: "name",
@@ -35,7 +68,19 @@ export function getColumns(handleDeleteMember: (memberId: string) => Promise<voi
3568
header: dict.dashboard.members.role,
3669
accessorKey: "role",
3770
},
38-
{
71+
];
72+
73+
if (isSuperuser) {
74+
columns.push({
75+
header: "Update",
76+
id: "update",
77+
cell: ({ row }: { row: Row<Member> }) => (
78+
<UpdateRoleButton member={row.original} onUpdate={handleUpdateMemberRole} />
79+
),
80+
});
81+
}
82+
83+
columns.push({
3984
id: "actions",
4085
cell: ({ row }: { row: Row<Member> }) => {
4186
const member = row.original;
@@ -81,6 +126,8 @@ export function getColumns(handleDeleteMember: (memberId: string) => Promise<voi
81126
</div>
82127
)
83128
},
84-
},
85-
];
129+
}
130+
);
131+
132+
return columns;
86133
}

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,22 @@ import {
1515
DialogTrigger,
1616
} from "@/components/ui/dialog"
1717
import { getColumns } from "./columns";
18-
import { deleteOrganisationUser, getAllOrganisationMembers, inviteOrganisationUser } from "@/models/organisation";
18+
import { deleteOrganisationUser, getAllOrganisationMembers, inviteOrganisationUser, updateOrganisationMemberRole } from "@/models/organisation";
1919
import { DataTable } from "@/components/ui/data-table";
2020
import { Input } from "@/components/ui/input";
2121
import { Label } from "@/components/ui/label";
22+
import { getIsSuperuser } from "@/models/user";
2223
import { useState } from "react";
2324
import { ApiError } from "@/lib/api";
2425

2526
export default function OrganisationMembers({ orgId, dict }: { orgId: string, dict: any }) {
2627
const queryClient = useQueryClient();
2728

29+
const { data: isSuperuserData } = useQuery({
30+
queryKey: ["is_superuser"],
31+
queryFn: () => getIsSuperuser(),
32+
});
33+
2834
let { data: members } = useQuery({
2935
queryKey: [`${orgId}-members`],
3036
queryFn: () => getAllOrganisationMembers(orgId),
@@ -37,7 +43,13 @@ export default function OrganisationMembers({ orgId, dict }: { orgId: string, di
3743
await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] });
3844
}
3945

40-
const userColumns = getColumns(handleDeleteMember, dict);
46+
const handleUpdateMemberRole = async (memberId: string, newRole: "Admin" | "User") => {
47+
await updateOrganisationMemberRole(orgId, memberId, newRole);
48+
await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] });
49+
}
50+
51+
const isSuperuser = isSuperuserData?.is_superuser ?? false;
52+
const userColumns = getColumns(handleDeleteMember, handleUpdateMemberRole, dict, isSuperuser);
4153

4254
return (
4355
<div className="flex flex-col gap-3">

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default async function OrganisationMembersPage({ params }: { params: Prom
1212

1313
await queryClient.prefetchQuery({
1414
queryKey: ["is_superuser"],
15-
queryFn: getIsSuperuser,
15+
queryFn: () => getIsSuperuser(),
1616
});
1717

1818
await queryClient.prefetchQuery({

frontend-nextjs/src/models/organisation.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ export async function getOrganisationAdmins(orgId: string): Promise<Member[]> {
5656
return await apiRequest<Member[]>(`/api/v1/organisation/${orgId}/admins`);
5757
}
5858

59+
export async function updateOrganisationAdmins(orgId: string, memberIds: string[]): Promise<AppMessage> {
60+
return await apiRequest<AppMessage>(`/api/v1/organisation/${orgId}/admins`, {
61+
method: "PUT",
62+
body: { members: memberIds.map((id) => Number(id)) },
63+
});
64+
}
65+
66+
export async function updateOrganisationMemberRole(
67+
orgId: string,
68+
userId: string,
69+
role: OrganisationRole
70+
): Promise<AppMessage> {
71+
return await apiRequest<AppMessage>(`/api/v1/organisation/${orgId}/member`, {
72+
method: "PUT",
73+
body: { user_id: Number(userId), role },
74+
});
75+
}
76+
5977
export async function getOrganisationUsers(orgId: string): Promise<Member[]> {
6078
return await apiRequest<Member[]>(`/api/v1/organisation/${orgId}/users`);
6179
}

0 commit comments

Comments
 (0)