feat(v1.1.8): admin HTTP — /apps/{id}/users + /invitations

users_admin_api router merged into the guarded admin tree at
/api/v1/admin/apps/{id_or_slug}/users/* + /invitations/*.

Endpoints (capability gates applied via the service layer):

  GET    /users                                       AppUsersRead
  GET    /users/{user_id}                             AppUsersRead
  POST   /users                                       AppUsersWrite
  PATCH  /users/{user_id}                             AppUsersWrite
  DELETE /users/{user_id}                             AppUsersWrite (admins satisfy implicitly)
  POST   /users/{user_id}/reset-password              AppUsersAdmin -> one-shot token
  POST   /users/{user_id}/revoke-sessions             AppUsersAdmin
  GET    /invitations                                 AppUsersAdmin
  POST   /invitations                                 AppUsersAdmin
  DELETE /invitations/{invite_id}                     AppUsersAdmin

DTOs never include password_hash, session tokens, or unrotated
reset tokens. The reset-password endpoint returns the raw one-shot
token exactly once in the response body so an admin can paste it
into a manual reset link (TTL 1h by default).

Synth_cx() factors the admin-side cx construction into one place
(marked) so the SDK and admin code paths share the service's authz
fan-out. Admin-mediated trait methods (admin_create_invitation,
admin_reset_password_token, admin_revoke_all_sessions,
list_invitations, revoke_invitation) take &Principal directly,
not the synthesized cx.

UsersServiceImpl: removed the NOT_YET_IMPL constant + unused
auth alias (every method has a real implementation now). Added
Serialize+Deserialize on the AppUser / Invitation shared DTOs so
the admin HTTP layer doesn't need a parallel set of types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 12:18:29 +02:00
parent 3af99873c3
commit aa2631ff61
5 changed files with 621 additions and 23 deletions

View File

@@ -19,6 +19,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};
@@ -27,7 +28,7 @@ use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};
/// / `list` / etc. Never carries the password hash. The `roles` field
/// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage;
/// pre-commit-9 it's always empty).
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppUser {
pub id: AppUserId,
pub app_id: AppId,
@@ -41,7 +42,7 @@ pub struct AppUser {
}
/// Pending invitation row returned by the admin invitations API.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invitation {
pub id: InvitationId,
pub app_id: AppId,
@@ -336,6 +337,18 @@ pub trait UsersService: Send + Sync {
user_id: AppUserId,
) -> Result<u64, UsersError>;
/// Admin issues an invitation from the dashboard (distinct from
/// the SDK `users::invite` path, which takes an SdkCallCx tied to
/// the calling script). Returns the created invitation so the
/// admin UI can render it without a re-fetch.
async fn admin_create_invitation(
&self,
principal: &Principal,
app_id: AppId,
email: &str,
opts: InviteOpts,
) -> Result<Invitation, UsersError>;
/// Admin lists pending invitations for the app's invitations tab.
async fn list_invitations(
&self,
@@ -515,6 +528,15 @@ impl UsersService for NoopUsersService {
) -> Result<u64, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn admin_create_invitation(
&self,
_principal: &Principal,
_app_id: AppId,
_email: &str,
_opts: InviteOpts,
) -> Result<Invitation, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn list_invitations(
&self,
_principal: &Principal,