@@ -33,6 +33,18 @@ async function resolvePostgresTarget(rootDir?: string): Promise<PostgresTarget>
3333 } ;
3434}
3535
36+ /**
37+ * Quote a string for safe interpolation into a SQL string literal. Postgres
38+ * standard-conforming mode treats `'` as the only character that closes a
39+ * literal, so doubling it is sufficient. We rely on this rather than psql's
40+ * `-v name=…` + `:'name'` substitution because that path is silently a no-op
41+ * under `psql -c` in modern psql (15+) — the literal `:'name'` ends up in
42+ * the SQL and produces a syntax error.
43+ */
44+ function quoteSqlLiteral ( value : string ) : string {
45+ return `'${ value . replace ( / ' / g, "''" ) } '` ;
46+ }
47+
3648/**
3749 * Run `psql -c <sql>` inside the postgis container via the generated
3850 * compose file. Returns trimmed stdout (minus trailing empty lines).
@@ -75,58 +87,51 @@ export async function execPsql(
7587 return ( result . stdout ?? "" ) . trim ( ) ;
7688}
7789
78- export async function promoteUserToAdmin ( email : string , rootDir ?: string ) : Promise < void > {
79- if ( ! email ?. includes ( "@" ) ) {
80- throw new Error ( `"${ email } " does not look like an email address` ) ;
81- }
82- const target = await resolvePostgresTarget ( rootDir ) ;
90+ interface UpdateUserOptions {
91+ /** Email of the user to update. */
92+ email : string ;
93+ /** SQL `SET` clause body, e.g. `role = 'admin'`. */
94+ setClause : string ;
95+ /** Verb shown in the not-found / multiple-matches error/warning text. */
96+ actionVerb : string ;
97+ rootDir ?: string ;
98+ }
8399
84- // Parameterise via psql's `-v` / `:'var'` quoting so the email is safely
85- // escaped by libpq rather than injected into the SQL literal.
86- const paths = repoPaths ( rootDir ) ;
87- const result = await execa (
88- "docker" ,
89- [
90- "compose" ,
91- "-f" ,
92- paths . composeOutPath ,
93- "exec" ,
94- "-T" ,
95- target . serviceId ,
96- "psql" ,
97- "-U" ,
98- target . user ,
99- "-d" ,
100- target . db ,
101- "-v" ,
102- "ON_ERROR_STOP=1" ,
103- "--no-psqlrc" ,
104- "-A" ,
105- "-t" ,
106- "-v" ,
107- `email=${ email } ` ,
108- "-c" ,
109- `UPDATE "user" SET role = 'admin' WHERE email = :'email' RETURNING id;` ,
110- ] ,
111- { cwd : paths . infraDir , reject : false } ,
112- ) ;
113- if ( result . exitCode !== 0 ) {
114- throw new Error (
115- `psql failed (exit ${ result . exitCode } ): ${ result . stderr ?. trim ( ) || result . stdout ?. trim ( ) || "no output" } ` ,
116- ) ;
100+ /**
101+ * Run `UPDATE "user" SET <setClause> WHERE email = '<email>' RETURNING id;`
102+ * inside the postgis container. Centralises the email validation, escaping,
103+ * exec wiring, and "no rows matched" error messaging used by every user
104+ * mutation command.
105+ */
106+ async function updateUserByEmail ( opts : UpdateUserOptions ) : Promise < string [ ] > {
107+ if ( ! opts . email ?. includes ( "@" ) ) {
108+ throw new Error ( `"${ opts . email } " does not look like an email address` ) ;
117109 }
118- const updatedIds = ( result . stdout ?? "" )
110+ const target = await resolvePostgresTarget ( opts . rootDir ) ;
111+ const sql = `UPDATE "user" SET ${ opts . setClause } WHERE email = ${ quoteSqlLiteral ( opts . email ) } RETURNING id;` ;
112+ const stdout = await execPsql ( target , sql , opts . rootDir ) ;
113+ const ids = stdout
119114 . split ( "\n" )
120115 . map ( ( s ) => s . trim ( ) )
121116 . filter ( Boolean ) ;
122- if ( updatedIds . length === 0 ) {
117+ if ( ids . length === 0 ) {
123118 throw new Error (
124- `No user found with email "${ email } ". Sign up through the web UI first, then re-run this command.` ,
119+ `No user found with email "${ opts . email } ". Sign up through the web UI first, then re-run this command.` ,
125120 ) ;
126121 }
127- if ( updatedIds . length > 1 ) {
128- log . warn ( `${ updatedIds . length } users matched "${ email } " — promoted all of them` ) ;
122+ if ( ids . length > 1 ) {
123+ log . warn ( `${ ids . length } users matched "${ opts . email } " — ${ opts . actionVerb } all of them` ) ;
129124 }
125+ return ids ;
126+ }
127+
128+ export async function promoteUserToAdmin ( email : string , rootDir ?: string ) : Promise < void > {
129+ await updateUserByEmail ( {
130+ email,
131+ setClause : "role = 'admin'" ,
132+ actionVerb : "promoted" ,
133+ rootDir,
134+ } ) ;
130135}
131136
132137/**
@@ -137,54 +142,21 @@ export async function promoteUserToAdmin(email: string, rootDir?: string): Promi
137142 * configures SMTP, and self-service signup works for everyone else.
138143 */
139144export async function markEmailVerified ( email : string , rootDir ?: string ) : Promise < void > {
140- if ( ! email ?. includes ( "@" ) ) {
141- throw new Error ( `"${ email } " does not look like an email address` ) ;
142- }
143- const target = await resolvePostgresTarget ( rootDir ) ;
144- const paths = repoPaths ( rootDir ) ;
145- const result = await execa (
146- "docker" ,
147- [
148- "compose" ,
149- "-f" ,
150- paths . composeOutPath ,
151- "exec" ,
152- "-T" ,
153- target . serviceId ,
154- "psql" ,
155- "-U" ,
156- target . user ,
157- "-d" ,
158- target . db ,
159- "-v" ,
160- "ON_ERROR_STOP=1" ,
161- "--no-psqlrc" ,
162- "-A" ,
163- "-t" ,
164- "-v" ,
165- `email=${ email } ` ,
166- "-c" ,
167- `UPDATE "user" SET email_verified = true WHERE email = :'email' RETURNING id;` ,
168- ] ,
169- { cwd : paths . infraDir , reject : false } ,
170- ) ;
171- if ( result . exitCode !== 0 ) {
172- throw new Error (
173- `psql failed (exit ${ result . exitCode } ): ${ result . stderr ?. trim ( ) || result . stdout ?. trim ( ) || "no output" } ` ,
174- ) ;
175- }
176- const updatedIds = ( result . stdout ?? "" )
177- . split ( "\n" )
178- . map ( ( s ) => s . trim ( ) )
179- . filter ( Boolean ) ;
180- if ( updatedIds . length === 0 ) {
181- throw new Error (
182- `No user found with email "${ email } ". Sign up through the web UI first, then re-run this command.` ,
183- ) ;
184- }
185- if ( updatedIds . length > 1 ) {
186- log . warn ( `${ updatedIds . length } users matched "${ email } " — verified all of them` ) ;
187- }
145+ await updateUserByEmail ( {
146+ email,
147+ setClause : "email_verified = true" ,
148+ actionVerb : "verified" ,
149+ rootDir,
150+ } ) ;
151+ }
152+
153+ export async function demoteUserFromAdmin ( email : string , rootDir ?: string ) : Promise < void > {
154+ await updateUserByEmail ( {
155+ email,
156+ setClause : "role = 'user'" ,
157+ actionVerb : "demoted" ,
158+ rootDir,
159+ } ) ;
188160}
189161
190162export async function listUsers (
@@ -261,44 +233,7 @@ export function registerUsersCommands(program: Command): void {
261233 . description ( "Remove admin role from a user (sets role back to user)" )
262234 . action ( async ( email : string ) => {
263235 try {
264- const target = await resolvePostgresTarget ( ) ;
265- const paths = repoPaths ( ) ;
266- const result = await execa (
267- "docker" ,
268- [
269- "compose" ,
270- "-f" ,
271- paths . composeOutPath ,
272- "exec" ,
273- "-T" ,
274- target . serviceId ,
275- "psql" ,
276- "-U" ,
277- target . user ,
278- "-d" ,
279- target . db ,
280- "-v" ,
281- "ON_ERROR_STOP=1" ,
282- "--no-psqlrc" ,
283- "-A" ,
284- "-t" ,
285- "-v" ,
286- `email=${ email } ` ,
287- "-c" ,
288- `UPDATE "user" SET role = 'user' WHERE email = :'email' RETURNING id;` ,
289- ] ,
290- { cwd : paths . infraDir , reject : false } ,
291- ) ;
292- if ( result . exitCode !== 0 ) {
293- throw new Error ( result . stderr ?. trim ( ) || result . stdout ?. trim ( ) || "psql failed" ) ;
294- }
295- const ids = ( result . stdout ?? "" )
296- . split ( "\n" )
297- . map ( ( s ) => s . trim ( ) )
298- . filter ( Boolean ) ;
299- if ( ids . length === 0 ) {
300- throw new Error ( `No user found with email "${ email } "` ) ;
301- }
236+ await demoteUserFromAdmin ( email ) ;
302237 log . ok ( `Demoted ${ email } to user` ) ;
303238 } catch ( err ) {
304239 log . err ( ( err as Error ) . message ) ;
0 commit comments