11use std:: { env, path:: Path } ;
22
3+ use rand:: { distributions:: Alphanumeric , Rng } ;
34use serde:: Serialize ;
45use sqlx:: {
56 sqlite:: { SqliteConnectOptions , SqliteJournalMode , SqliteSynchronous } ,
67 Connection , FromRow , SqliteConnection , SqlitePool ,
78} ;
89
10+ /// Generate a user-facing placeholder display name for users whose real name we don't yet
11+ /// know. The Kinde sub and the user's email are both considered sensitive/ugly to expose
12+ /// in the UI, so we deliberately surface neither — callers should use this helper instead
13+ /// of making something up. The 4-character suffix exists purely so two unnamed users in the
14+ /// same account list are distinguishable at a glance.
15+ #[ must_use]
16+ pub fn generate_unnamed_placeholder ( ) -> String {
17+ let suffix: String = rand:: thread_rng ( )
18+ . sample_iter ( & Alphanumeric )
19+ . take ( 4 )
20+ . map ( char:: from)
21+ . collect ( ) ;
22+ format ! ( "Unnamed-{suffix}" )
23+ }
24+
925#[ derive( Clone , Debug ) ]
1026pub struct GlobalDB {
1127 pool : SqlitePool ,
@@ -95,12 +111,16 @@ impl GlobalDB {
95111 Ok ( Self { pool } )
96112 }
97113
98- /// Create or find a global user by `kinde_id`. Updates `display_name` and email if changed.
114+ /// Create a new global user, or return the existing one. The `name` parameter is only
115+ /// used on creation — once a row exists, its `display_name` is treated as user-owned and
116+ /// is never overwritten by this function. Manual updates made via
117+ /// [`Self::update_user_display_name`] (by the user themselves or an admin) therefore
118+ /// stick, and subsequent Kinde logins won't silently clobber them. `email` and
119+ /// `is_kinde_admin` are still synced on every call so that email changes and Kinde
120+ /// admin-role revocations propagate.
99121 ///
100- /// `is_kinde_admin` is written verbatim — flipping it false on a later auth correctly
101- /// revokes admin status if the user is no longer granted via the Admin page. The
102- /// `admin_grant` column is only touched by [`Self::set_user_admin`], so Admin-page
103- /// grants survive auth.
122+ /// `admin_grant` is never touched here — it is only set via
123+ /// [`Self::set_user_admin`] from the Admin page.
104124 ///
105125 /// # Errors
106126 /// Returns an error on database failure.
@@ -121,24 +141,21 @@ impl GlobalDB {
121141 . await ?;
122142
123143 if let Some ( mut user) = existing {
124- let name_changed = user . display_name != name || user. email . as_deref ( ) != email;
144+ let email_changed = email . is_some ( ) && user. email . as_deref ( ) != email;
125145 let kinde_admin_changed = user. is_kinde_admin != is_kinde_admin;
126- if name_changed || kinde_admin_changed {
146+ if email_changed || kinde_admin_changed {
127147 sqlx:: query (
128148 r"UPDATE global_user
129- SET display_name = ?,
130- email = COALESCE(?, email),
149+ SET email = COALESCE(?, email),
131150 is_kinde_admin = ?
132151 WHERE id = ?" ,
133152 )
134- . bind ( name)
135153 . bind ( email)
136154 . bind ( is_kinde_admin)
137155 . bind ( user. id )
138156 . execute ( & self . pool )
139157 . await ?;
140- user. display_name = name. to_string ( ) ;
141- if email. is_some ( ) {
158+ if email_changed {
142159 user. email = email. map ( String :: from) ;
143160 }
144161 if kinde_admin_changed {
@@ -172,21 +189,21 @@ impl GlobalDB {
172189 } )
173190 }
174191
175- /// Find a global user by `kinde_id`, creating one with a placeholder
176- /// display name if none exists. Unlike [`Self::ensure_global_user`], this
177- /// does NOT update the display name or email of an existing user — use it
178- /// from code paths that don't yet have a trusted name for the user.
192+ /// Find a global user by `kinde_id`, creating one with a generated `"Unnamed-XXXX"`
193+ /// placeholder if none exists. Unlike [`Self::ensure_global_user`], this does NOT
194+ /// update the display name or email of an existing user — use it from code paths that
195+ /// don't yet have a trusted name for the user.
179196 ///
180- /// `is_kinde_admin` is still synced (written verbatim) even on the
181- /// no-name path, so the Kinde admin role is recognised on the very first
182- /// REST request even before the WS auth flow has populated a real name.
197+ /// The placeholder is generated by [`generate_unnamed_placeholder`] and is deliberately
198+ /// not derived from the Kinde sub or the user's email: exposing either in the UI is
199+ /// considered a privacy/UX regression. `is_kinde_admin` is still synced verbatim so the
200+ /// Kinde admin role is recognised even on a user's first request.
183201 ///
184202 /// # Errors
185203 /// Returns an error on database failure.
186204 pub async fn find_or_create_global_user (
187205 & self ,
188206 kinde_id : & str ,
189- placeholder_name : & str ,
190207 email : Option < & str > ,
191208 is_kinde_admin : bool ,
192209 ) -> Result < GlobalUser , sqlx:: Error > {
@@ -203,12 +220,13 @@ impl GlobalDB {
203220 return Ok ( user) ;
204221 }
205222
223+ let placeholder_name = generate_unnamed_placeholder ( ) ;
206224 let id = sqlx:: query_scalar :: < _ , i64 > (
207225 r"INSERT INTO global_user (kinde_id, display_name, email, is_kinde_admin)
208226 VALUES (?, ?, ?, ?) RETURNING id" ,
209227 )
210228 . bind ( kinde_id)
211- . bind ( placeholder_name)
229+ . bind ( & placeholder_name)
212230 . bind ( email)
213231 . bind ( is_kinde_admin)
214232 . fetch_one ( & self . pool )
@@ -217,14 +235,41 @@ impl GlobalDB {
217235 Ok ( GlobalUser {
218236 id,
219237 kinde_id : kinde_id. to_string ( ) ,
220- display_name : placeholder_name. to_string ( ) ,
238+ display_name : placeholder_name,
221239 is_admin : is_kinde_admin,
222240 is_kinde_admin,
223241 admin_grant : false ,
224242 email : email. map ( String :: from) ,
225243 } )
226244 }
227245
246+ /// Look up a global user by `kinde_id` and sync their `is_kinde_admin` flag in place if
247+ /// it differs from the passed value. Returns `None` when no row exists — callers that
248+ /// cannot create the user themselves (e.g. `check_admin`, which has no trusted display
249+ /// name) should treat this as "not authorised" rather than creating a placeholder row.
250+ ///
251+ /// # Errors
252+ /// Returns an error on database failure.
253+ pub async fn sync_is_kinde_admin_by_kinde_id (
254+ & self ,
255+ kinde_id : & str ,
256+ is_kinde_admin : bool ,
257+ ) -> Result < Option < GlobalUser > , sqlx:: Error > {
258+ let Some ( mut user) = self . get_global_user_by_kinde_id ( kinde_id) . await ? else {
259+ return Ok ( None ) ;
260+ } ;
261+ if user. is_kinde_admin != is_kinde_admin {
262+ sqlx:: query ( "UPDATE global_user SET is_kinde_admin = ? WHERE id = ?" )
263+ . bind ( is_kinde_admin)
264+ . bind ( user. id )
265+ . execute ( & self . pool )
266+ . await ?;
267+ user. is_kinde_admin = is_kinde_admin;
268+ user. is_admin = is_kinde_admin || user. admin_grant ;
269+ }
270+ Ok ( Some ( user) )
271+ }
272+
228273 /// Get a global user by `kinde_id`.
229274 ///
230275 /// # Errors
0 commit comments