@dooer/dooer-test-env 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/user-management.md +246 -0
- package/lib/cli.js +36 -21
- package/lib/command/partner.js +34 -57
- package/lib/command/remote-environment.js +292 -0
- package/lib/command/target.js +18 -15
- package/lib/command/user.js +53 -77
- package/lib/remote.js +357 -0
- package/lib/users.js +200 -206
- package/package.json +2 -2
- package/readme.md +53 -12
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
const readline = require('readline')
|
|
2
|
+
const chalk = require('chalk')
|
|
3
|
+
const qrcode = require('qrcode-terminal')
|
|
4
|
+
const remote = require('../remote')
|
|
5
|
+
|
|
6
|
+
// Log in to a remote Dooer environment over its public GraphQL endpoint and keep the session in the
|
|
7
|
+
// keychain, so the other commands (user / partner) can act as you without any cluster access.
|
|
8
|
+
|
|
9
|
+
// Everything this command prints is information you need to read, so it goes out in the terminal's own
|
|
10
|
+
// foreground colour. chalk.gray renders as near-invisible on a dark profile (Jimmy 2026-09-04).
|
|
11
|
+
const plain = (s) => s
|
|
12
|
+
|
|
13
|
+
const envOption = (y) =>
|
|
14
|
+
y.option('env', {
|
|
15
|
+
type: 'string',
|
|
16
|
+
default: 'local',
|
|
17
|
+
describe: `environment: ${remote.envNames().join(' | ')}`,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// ── prompts ─────────────────────────────────────────────────────────────────
|
|
21
|
+
function ask(question) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
if (!process.stdin.isTTY) {
|
|
24
|
+
reject(new Error(`need a terminal to ask for "${question.trim()}" — pass it as a flag instead`))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
28
|
+
rl.question(question, (answer) => {
|
|
29
|
+
rl.close()
|
|
30
|
+
resolve(answer.trim())
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Hidden input by reading raw keystrokes rather than overriding readline's internal `_writeToOutput`.
|
|
36
|
+
// That override races with input that arrives before the prompt is drawn and leaves the line half-erased;
|
|
37
|
+
// raw mode simply never echoes, which is both simpler and what it looks like it does.
|
|
38
|
+
function askHidden(question) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const { stdin, stdout } = process
|
|
41
|
+
if (!stdin.isTTY) {
|
|
42
|
+
reject(new Error(`need a terminal to ask for "${question.trim()}" — pass it as a flag instead`))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
stdout.write(question)
|
|
46
|
+
let value = ''
|
|
47
|
+
const wasRaw = stdin.isRaw
|
|
48
|
+
stdin.setRawMode(true)
|
|
49
|
+
stdin.resume()
|
|
50
|
+
stdin.setEncoding('utf8')
|
|
51
|
+
const done = (err, out) => {
|
|
52
|
+
stdin.removeListener('data', onData)
|
|
53
|
+
stdin.setRawMode(wasRaw)
|
|
54
|
+
stdin.pause()
|
|
55
|
+
stdout.write('\n')
|
|
56
|
+
err ? reject(err) : resolve(out)
|
|
57
|
+
}
|
|
58
|
+
const onData = (chunk) => {
|
|
59
|
+
for (const ch of chunk) {
|
|
60
|
+
if (ch === '\r' || ch === '\n' || ch === '') return done(null, value)
|
|
61
|
+
if (ch === '') return done(new Error('cancelled')) // Ctrl-C
|
|
62
|
+
if (ch === '' || ch === '\b') value = value.slice(0, -1)
|
|
63
|
+
else if (ch >= ' ') value += ch
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
stdin.on('data', onData)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── terminal QR ─────────────────────────────────────────────────────────────
|
|
71
|
+
// The BankID payload changes every second, so the QR is redrawn in place: render, remember how many lines
|
|
72
|
+
// it occupied, then move the cursor back up over them before the next frame. Falls back to plain appending
|
|
73
|
+
// when stdout is not a terminal (piped output, CI) so the log stays readable.
|
|
74
|
+
function qrRenderer() {
|
|
75
|
+
let lastLines = 0
|
|
76
|
+
return (payload, statusLine) => {
|
|
77
|
+
qrcode.generate(payload, { small: true }, (art) => {
|
|
78
|
+
const block = `${art}\n${statusLine}\n`
|
|
79
|
+
const lines = block.split('\n').length - 1
|
|
80
|
+
if (process.stdout.isTTY && lastLines) process.stdout.write(`[${lastLines}A[0J`)
|
|
81
|
+
process.stdout.write(block)
|
|
82
|
+
lastLines = process.stdout.isTTY ? lines : 0
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── shared: store a token and report who it belongs to ──────────────────────
|
|
88
|
+
// Detail lines are printed in the terminal's default colour: chalk.gray renders as near-invisible on a
|
|
89
|
+
// dark profile, and everything below is information you need to read (Jimmy 2026-09-04).
|
|
90
|
+
const detail = (k, v) => console.log(` ${chalk.cyan(k.padEnd(12))}${v}`)
|
|
91
|
+
|
|
92
|
+
async function storeAndReport(env, token) {
|
|
93
|
+
remote.tokenSet(env.name, token)
|
|
94
|
+
const me = await remote.currentUser(env, token)
|
|
95
|
+
const exp = remote.expiry(token)
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.green(`\nlogged in to ${env.label}`) +
|
|
98
|
+
(me ? ` as ${chalk.bold(`${me.firstName} ${me.lastName}`)} <${me.email}>` : '')
|
|
99
|
+
)
|
|
100
|
+
if (me) {
|
|
101
|
+
detail('user id', me.id)
|
|
102
|
+
// Which of your accounts this is. `admin` is required to create users; partner operations need a
|
|
103
|
+
// partner-level session; a `customer` token can do neither.
|
|
104
|
+
detail('user type', me.userType === 'admin' ? chalk.bold(me.userType) : me.userType)
|
|
105
|
+
}
|
|
106
|
+
detail('session', `stored in keychain${exp ? `, valid until ${exp.at.toISOString()}` : ''}`)
|
|
107
|
+
detail('check with', `dooer-test-env remote-environment status --env ${env.name}`)
|
|
108
|
+
console.log()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
command: 'remote-environment <command>',
|
|
113
|
+
aliases: ['remote'],
|
|
114
|
+
describe: 'log in to an environment through its public GraphQL API (session kept in the keychain)',
|
|
115
|
+
builder: (y) =>
|
|
116
|
+
y
|
|
117
|
+
.command({
|
|
118
|
+
command: 'login',
|
|
119
|
+
describe: 'log in with BankID (default) or email + password',
|
|
120
|
+
builder: (y2) =>
|
|
121
|
+
envOption(y2)
|
|
122
|
+
.option('bankid', { type: 'boolean', describe: 'log in with BankID (default when no --email)' })
|
|
123
|
+
.option('customer', {
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
describe: 'BankID: sign in as your CUSTOMER account instead of your professional one',
|
|
126
|
+
})
|
|
127
|
+
.option('personnummer', { type: 'string', describe: 'BankID: your personnummer (default: any, via QR)' })
|
|
128
|
+
.option('email', { type: 'string', describe: 'log in with this email + password instead of BankID' })
|
|
129
|
+
.option('password', { type: 'string', describe: 'password (omit to be prompted, which is safer)' }),
|
|
130
|
+
handler: async (argv) => {
|
|
131
|
+
const env = remote.resolveEnv(argv.env)
|
|
132
|
+
const usePassword = Boolean(argv.email || argv.password) && !argv.bankid
|
|
133
|
+
|
|
134
|
+
if (usePassword) {
|
|
135
|
+
const email = argv.email || (await ask('email: '))
|
|
136
|
+
const password = argv.password || (await askHidden('password: '))
|
|
137
|
+
console.log(plain(`\nauthenticating against ${env.url} …`))
|
|
138
|
+
await storeAndReport(env, await remote.loginWithPassword(env, email, password))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Default to HQ's flow — the partner authentication session, which resolves your professional
|
|
143
|
+
// (`hi`) account. `loginWithBankIdV2` is the other route, but service-accounts pins it to
|
|
144
|
+
// user_type 'customer' (lib/login-with-bank-id.js:199), so it can only ever return that account.
|
|
145
|
+
const asCustomer = Boolean(argv.customer)
|
|
146
|
+
console.log(
|
|
147
|
+
chalk.bold(`\nBankID login — ${env.label}`) +
|
|
148
|
+
plain(
|
|
149
|
+
`\n${env.url}\n` +
|
|
150
|
+
(asCustomer ? 'signing in as your customer account\n' : '') +
|
|
151
|
+
`\nOpen the BankID app and scan this code (it refreshes every second):\n`
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
const draw = qrRenderer()
|
|
155
|
+
const onFrame = ({ qr, hintCode }) => {
|
|
156
|
+
if (!qr) return
|
|
157
|
+
draw(qr, plain(` ${remote.HINTS[hintCode] || hintCode || 'starting…'} (Ctrl-C to abort)`))
|
|
158
|
+
}
|
|
159
|
+
const token = asCustomer
|
|
160
|
+
? await remote.loginWithBankId(env, { sePersonnummer: argv.personnummer, onFrame })
|
|
161
|
+
: await remote.loginWithPartnerBankId(env, { idNumber: argv.personnummer, onFrame })
|
|
162
|
+
await storeAndReport(env, token)
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
.command({
|
|
166
|
+
command: 'become [user]',
|
|
167
|
+
describe: 'as an ADMIN, assume a partner user to get a partner-level session (back-office “Become user”)',
|
|
168
|
+
builder: (y2) =>
|
|
169
|
+
envOption(y2)
|
|
170
|
+
.positional('user', { type: 'string', describe: 'user id or email of the partner user' })
|
|
171
|
+
.option('partner', { type: 'string', describe: 'which partner (needed only if they belong to several)' })
|
|
172
|
+
.option('revert', { type: 'boolean', describe: 'restore the session you had before becoming' }),
|
|
173
|
+
handler: async (argv) => {
|
|
174
|
+
const env = remote.resolveEnv(argv.env)
|
|
175
|
+
const u = require('../users')
|
|
176
|
+
|
|
177
|
+
if (argv.revert) {
|
|
178
|
+
const restored = remote.unstashToken(env.name)
|
|
179
|
+
if (!restored) throw new Error(`no stashed session for ${env.name} — nothing to revert to`)
|
|
180
|
+
const me = await remote.currentUser(env, restored)
|
|
181
|
+
console.log(chalk.green(`\nback to ${me.email} (${me.userType}) on ${env.label}\n`))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!argv.user) throw new Error('which user? pass a user id or email (or --revert)')
|
|
186
|
+
const user = await u.findUser(env, argv.user)
|
|
187
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
188
|
+
if (!partners.length) throw new Error(`${user.email} does not belong to any partner`)
|
|
189
|
+
const partner = argv.partner
|
|
190
|
+
? await u.findPartner(env, argv.partner)
|
|
191
|
+
: partners.length === 1
|
|
192
|
+
? partners[0]
|
|
193
|
+
: null
|
|
194
|
+
if (!partner) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${user.email} belongs to ${partners.length} partners — pick one with --partner:\n` +
|
|
197
|
+
partners.map((p) => ` ${p.name} (${p.domain})`).join('\n')
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
// Keep the admin token: becoming replaces the session, and you will want it back.
|
|
201
|
+
remote.stashToken(env.name)
|
|
202
|
+
const { token } = await remote.becomePartnerUser(env, { partnerId: partner.id, userId: user.id })
|
|
203
|
+
await storeAndReport(env, token)
|
|
204
|
+
console.log(` Revert with: dooer-test-env remote-environment become --revert --env ${env.name}\n`)
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
.command({
|
|
208
|
+
command: 'token',
|
|
209
|
+
describe: 'print the stored token for an environment — bare, for scripts',
|
|
210
|
+
builder: envOption,
|
|
211
|
+
handler: (argv) => {
|
|
212
|
+
const env = remote.resolveEnv(argv.env)
|
|
213
|
+
// Deliberately the ONLY thing on stdout: no colour, no label, no trailing prose, so
|
|
214
|
+
// `$(… token)` and `| pbcopy` both do the obvious thing. Failures go to stderr via cli.js.
|
|
215
|
+
process.stdout.write(remote.tokenFor(env.name) + '\n')
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
.command({
|
|
219
|
+
command: 'status',
|
|
220
|
+
describe: 'who you are logged in as, and until when',
|
|
221
|
+
builder: (y2) => envOption(y2).option('all', { type: 'boolean', describe: 'report every environment' }),
|
|
222
|
+
handler: async (argv) => {
|
|
223
|
+
const names = argv.all ? remote.envNames() : [remote.resolveEnv(argv.env).name]
|
|
224
|
+
console.log()
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const env = remote.resolveEnv(name)
|
|
227
|
+
const token = remote.tokenGet(name)
|
|
228
|
+
const head = `${chalk.bold(env.label.padEnd(22))}`
|
|
229
|
+
if (!token) {
|
|
230
|
+
// The local stack needs no login — it is signed with the shared dev keypair this CLI holds.
|
|
231
|
+
const note =
|
|
232
|
+
name === 'local' ? plain('no session — uses a minted service token') : chalk.yellow('not logged in')
|
|
233
|
+
console.log(`${head}${note}`)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const exp = remote.expiry(token)
|
|
237
|
+
if (exp && exp.expired) {
|
|
238
|
+
console.log(`${head}${chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)}`)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
let me = null
|
|
242
|
+
try {
|
|
243
|
+
me = await remote.currentUser(env, token)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.log(`${head}${chalk.red(`session rejected — ${e.message}`)}`)
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
console.log(
|
|
249
|
+
`${head}${chalk.green(me ? `${me.firstName} ${me.lastName} <${me.email}>` : 'valid session')}` +
|
|
250
|
+
plain(exp ? ` until ${exp.at.toISOString()}` : '')
|
|
251
|
+
)
|
|
252
|
+
// The account type decides what this session may do, so it belongs in the whoami line.
|
|
253
|
+
if (me) console.log(`${''.padEnd(22)}${me.id} ${chalk.cyan(me.userType || 'unknown type')}`)
|
|
254
|
+
}
|
|
255
|
+
console.log()
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
.command({
|
|
259
|
+
command: 'logout',
|
|
260
|
+
describe: 'forget the stored session for an environment',
|
|
261
|
+
builder: (y2) => envOption(y2),
|
|
262
|
+
handler: (argv) => {
|
|
263
|
+
const env = remote.resolveEnv(argv.env)
|
|
264
|
+
const had = remote.tokenDelete(env.name)
|
|
265
|
+
console.log(had ? chalk.green(`\nforgot the ${env.label} session\n`) : plain(`\nno ${env.label} session\n`))
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
.command({
|
|
269
|
+
command: 'list',
|
|
270
|
+
describe: 'the environments this CLI knows and where they point',
|
|
271
|
+
handler: () => {
|
|
272
|
+
console.log()
|
|
273
|
+
for (const name of remote.envNames()) {
|
|
274
|
+
const env = remote.resolveEnv(name)
|
|
275
|
+
const token = remote.tokenGet(name)
|
|
276
|
+
const exp = token ? remote.expiry(token) : null
|
|
277
|
+
const state = !token
|
|
278
|
+
? name === 'local'
|
|
279
|
+
? plain('service token')
|
|
280
|
+
: chalk.yellow('not logged in')
|
|
281
|
+
: exp && exp.expired
|
|
282
|
+
? chalk.yellow('expired')
|
|
283
|
+
: chalk.green('logged in')
|
|
284
|
+
console.log(` ${name.padEnd(12)}${env.url.padEnd(36)}${state}`)
|
|
285
|
+
}
|
|
286
|
+
console.log()
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
.demandCommand(1, 'Use: remote-environment login | status | logout | list')
|
|
290
|
+
.strict(),
|
|
291
|
+
handler: () => {},
|
|
292
|
+
}
|
package/lib/command/target.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
const chalk = require('chalk')
|
|
2
|
+
const remote = require('../remote')
|
|
2
3
|
|
|
3
|
-
// Shared bits for the environment-addressed commands (user / partner).
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
// Shared bits for the environment-addressed commands (user / partner). These reach an environment through
|
|
5
|
+
// its PUBLIC GraphQL endpoint, so the target is an ENVIRONMENT, not a k8s namespace — same `--env` word
|
|
6
|
+
// the remote-environment commands use. The local stack is the default.
|
|
7
|
+
const envOption = (y) =>
|
|
8
|
+
y.option('env', {
|
|
9
|
+
type: 'string',
|
|
10
|
+
default: 'local',
|
|
11
|
+
describe: `environment: ${remote.envNames().join(' | ')}`,
|
|
12
|
+
})
|
|
9
13
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
|
|
13
|
-
.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
14
|
+
const writeOption = (y) =>
|
|
15
|
+
y.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// Label + value, value in the terminal's own foreground colour — chalk.gray is unreadable on a dark
|
|
18
|
+
// profile and these lines carry the information (Jimmy 2026-09-04).
|
|
19
|
+
const detail = (k, v) => console.log(` ${chalk.cyan(k.padEnd(14))}${v === null || v === undefined ? '-' : v}`)
|
|
16
20
|
|
|
17
|
-
// Minimal column-aligned printer: `columns` is [[header, rowFn], …].
|
|
18
|
-
// as a dim dash so an empty column still lines up.
|
|
21
|
+
// Minimal column-aligned printer: `columns` is [[header, rowFn], …].
|
|
19
22
|
function printTable(rows, columns) {
|
|
20
23
|
if (!rows.length) {
|
|
21
|
-
console.log(
|
|
24
|
+
console.log(' (none)')
|
|
22
25
|
return
|
|
23
26
|
}
|
|
24
27
|
const cell = (v) => (v === null || v === undefined || v === '' ? '-' : String(v))
|
|
@@ -28,4 +31,4 @@ function printTable(rows, columns) {
|
|
|
28
31
|
for (const b of body) console.log(' ' + b.map((v, i) => v.padEnd(widths[i])).join(' '))
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
module.exports = {
|
|
34
|
+
module.exports = { envOption, writeOption, detail, printTable }
|
package/lib/command/user.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const chalk = require('chalk')
|
|
2
2
|
const u = require('../users')
|
|
3
|
-
const
|
|
3
|
+
const remote = require('../remote')
|
|
4
|
+
const { envOption, writeOption, printTable, detail } = require('./target')
|
|
4
5
|
|
|
5
6
|
const date = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null)
|
|
6
7
|
|
|
@@ -13,27 +14,25 @@ module.exports = {
|
|
|
13
14
|
command: 'search [query]',
|
|
14
15
|
describe: 'find users by email, name, personnummer or id (no query = the most recent users)',
|
|
15
16
|
builder: (y2) =>
|
|
16
|
-
|
|
17
|
+
envOption(y2)
|
|
17
18
|
.positional('query', { type: 'string', describe: 'substring to match' })
|
|
18
19
|
.option('role', { choices: u.USER_ROLES, describe: 'only users of this role' })
|
|
19
20
|
.option('limit', { type: 'number', default: 20, describe: 'max rows' }),
|
|
20
21
|
handler: async (argv) => {
|
|
21
|
-
const
|
|
22
|
-
const rows = await u.
|
|
23
|
-
u.searchUsers(db, { query: argv.query, role: argv.role, limit: argv.limit })
|
|
24
|
-
)
|
|
22
|
+
const env = remote.resolveEnv(argv.env)
|
|
23
|
+
const rows = await u.searchUsers(env, { query: argv.query, role: argv.role, limit: argv.limit })
|
|
25
24
|
console.log(
|
|
26
|
-
chalk.bold(`\n${rows.length} user(s) in ${label
|
|
27
|
-
(argv.query ?
|
|
25
|
+
chalk.bold(`\n${rows.length} user(s) in ${env.label}`) +
|
|
26
|
+
(argv.query ? ` matching "${argv.query}"` : '') +
|
|
28
27
|
'\n'
|
|
29
28
|
)
|
|
30
29
|
printTable(rows, [
|
|
31
30
|
['ID', (r) => r.id],
|
|
32
31
|
['EMAIL', (r) => r.email],
|
|
33
|
-
['NAME', (r) => `${r.
|
|
34
|
-
['
|
|
35
|
-
['CREATED', (r) => date(r.
|
|
36
|
-
['STATUS', (r) => (r.
|
|
32
|
+
['NAME', (r) => `${r.firstName} ${r.lastName}`],
|
|
33
|
+
['TYPE', (r) => r.userType],
|
|
34
|
+
['CREATED', (r) => date(r.createdAt)],
|
|
35
|
+
['STATUS', (r) => (r.isInactivated ? `inactivated ${date(r.inactivatedAt)}` : 'active')],
|
|
37
36
|
])
|
|
38
37
|
console.log()
|
|
39
38
|
},
|
|
@@ -41,33 +40,25 @@ module.exports = {
|
|
|
41
40
|
.command({
|
|
42
41
|
command: 'get <user>',
|
|
43
42
|
describe: 'show one user (by id or email) and the partners they belong to',
|
|
44
|
-
builder: (y2) =>
|
|
43
|
+
builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
45
44
|
handler: async (argv) => {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
line('role', user.role)
|
|
59
|
-
line('personnummer', user.personnummer)
|
|
60
|
-
line('password', user.has_password ? 'set' : chalk.yellow('none — cannot log in with a password'))
|
|
61
|
-
line('language', user.language)
|
|
62
|
-
line('created', date(user.inserted_at))
|
|
63
|
-
line('status', user.inactivated_at ? chalk.yellow(`inactivated ${date(user.inactivated_at)}`) : 'active')
|
|
64
|
-
if (user.internal) line('internal', 'yes')
|
|
45
|
+
const env = remote.resolveEnv(argv.env)
|
|
46
|
+
const user = await u.findUser(env, argv.user)
|
|
47
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
48
|
+
console.log(chalk.bold(`\n${user.firstName} ${user.lastName}`) + ` (${env.label})\n`)
|
|
49
|
+
detail('id', user.id)
|
|
50
|
+
detail('email', `${user.email}${user.isEmailVerified ? ' (verified)' : chalk.yellow(' (unverified)')}`)
|
|
51
|
+
detail('user type', user.userType)
|
|
52
|
+
detail('personnummer', user.sePersonnummer || '-')
|
|
53
|
+
detail('language', user.language || '-')
|
|
54
|
+
detail('created', date(user.createdAt))
|
|
55
|
+
detail('status', user.isInactivated ? chalk.yellow(`inactivated ${date(user.inactivatedAt)}`) : 'active')
|
|
56
|
+
if (user.internal) detail('internal', 'yes')
|
|
65
57
|
console.log(chalk.bold(`\n partners (${partners.length})`))
|
|
66
58
|
printTable(partners, [
|
|
67
59
|
['PARTNER', (r) => r.name],
|
|
68
60
|
['LEVEL', (r) => r.level],
|
|
69
61
|
['STATUS', (r) => r.status],
|
|
70
|
-
['LAST LOGIN', (r) => date(r.lastLoginAt)],
|
|
71
62
|
['ID', (r) => r.id],
|
|
72
63
|
])
|
|
73
64
|
console.log()
|
|
@@ -77,7 +68,7 @@ module.exports = {
|
|
|
77
68
|
command: 'create',
|
|
78
69
|
describe: 'create a user (password generated unless given). Dry-run by default.',
|
|
79
70
|
builder: (y2) =>
|
|
80
|
-
|
|
71
|
+
writeOption(envOption(y2))
|
|
81
72
|
.option('email', { type: 'string', demandOption: true, describe: 'email address (the login)' })
|
|
82
73
|
.option('password', { type: 'string', describe: 'password to set (default: generated, printed once)' })
|
|
83
74
|
.option('role', { choices: u.USER_ROLES, default: 'hi', describe: 'user role at dooer' })
|
|
@@ -87,8 +78,7 @@ module.exports = {
|
|
|
87
78
|
.option('partner', { type: 'string', describe: 'also add to this partner (id, name or domain)' })
|
|
88
79
|
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level in --partner' }),
|
|
89
80
|
handler: async (argv) => {
|
|
90
|
-
const
|
|
91
|
-
u.guardWrite(ns, argv)
|
|
81
|
+
const env = remote.resolveEnv(argv.env)
|
|
92
82
|
const derived = u.namesFromEmail(argv.email)
|
|
93
83
|
const firstName = argv.firstName || derived.firstName
|
|
94
84
|
const lastName = argv.lastName || derived.lastName
|
|
@@ -96,71 +86,57 @@ module.exports = {
|
|
|
96
86
|
const generated = !argv.password
|
|
97
87
|
const personnummer = argv.personnummer ? u.normalizePersonnummer(argv.personnummer) : null
|
|
98
88
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
// Resolve the partner BEFORE creating anything, so a typo fails without leaving a half-made user.
|
|
90
|
+
const partner = argv.partner ? await u.findPartner(env, argv.partner) : null
|
|
91
|
+
if (partner && argv.role !== 'hi') {
|
|
92
|
+
throw new Error(`--partner needs a "hi" user; role "${argv.role}" cannot belong to a partner`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let created = null
|
|
96
|
+
if (argv.execute) {
|
|
97
|
+
created = await u.createUser(env, {
|
|
106
98
|
email: argv.email,
|
|
107
99
|
password,
|
|
108
100
|
role: argv.role,
|
|
109
101
|
personnummer,
|
|
110
102
|
firstName,
|
|
111
103
|
lastName,
|
|
112
|
-
execute: argv.execute,
|
|
113
104
|
})
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (partner && argv.execute) {
|
|
117
|
-
await u.assignPartner(db, {
|
|
118
|
-
user: { id: created.id, email: argv.email, role: argv.role },
|
|
119
|
-
partner,
|
|
120
|
-
level: argv.level,
|
|
121
|
-
execute: argv.execute,
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
return { created, partner }
|
|
125
|
-
})
|
|
105
|
+
if (partner) await u.assignPartner(env, { userId: created.id, partnerId: partner.id, level: argv.level })
|
|
106
|
+
}
|
|
126
107
|
|
|
127
108
|
console.log(
|
|
128
109
|
(argv.execute ? chalk.green('\ncreated user') : chalk.yellow('\nDRY RUN — nothing written')) +
|
|
129
|
-
|
|
110
|
+
` (${env.label})\n`
|
|
130
111
|
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (res.partner) line('partner', `${res.partner.name} — level ${argv.level}`)
|
|
112
|
+
if (created) detail('id', created.id)
|
|
113
|
+
detail('email', argv.email)
|
|
114
|
+
detail('name', `${firstName} ${lastName}`)
|
|
115
|
+
detail('user type', argv.role)
|
|
116
|
+
if (personnummer) detail('personnummer', personnummer)
|
|
117
|
+
detail('password', chalk.bold(password) + (generated ? ' (generated)' : ''))
|
|
118
|
+
if (partner) detail('partner', `${partner.name} — level ${argv.level}`)
|
|
139
119
|
if (generated && argv.execute) {
|
|
140
|
-
console.log(
|
|
120
|
+
console.log('\n The service stores only a hash — this is the only time the password is shown.')
|
|
141
121
|
}
|
|
142
|
-
if (!argv.execute) console.log(
|
|
122
|
+
if (!argv.execute) console.log('\n Re-run with --execute to create it.')
|
|
143
123
|
console.log()
|
|
144
124
|
},
|
|
145
125
|
})
|
|
146
126
|
.command({
|
|
147
127
|
command: 'partners <user>',
|
|
148
128
|
describe: 'list the partners a user belongs to, and at what level',
|
|
149
|
-
builder: (y2) =>
|
|
129
|
+
builder: (y2) => envOption(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
150
130
|
handler: async (argv) => {
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
})
|
|
156
|
-
console.log(
|
|
157
|
-
chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + chalk.gray(` (${label(ns)})\n`)
|
|
158
|
-
)
|
|
131
|
+
const env = remote.resolveEnv(argv.env)
|
|
132
|
+
const user = await u.findUser(env, argv.user)
|
|
133
|
+
const partners = await u.partnersForUser(env, user.id)
|
|
134
|
+
console.log(chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + ` (${env.label})\n`)
|
|
159
135
|
printTable(partners, [
|
|
160
136
|
['PARTNER', (r) => r.name],
|
|
137
|
+
['DOMAIN', (r) => r.domain],
|
|
161
138
|
['LEVEL', (r) => r.level],
|
|
162
139
|
['STATUS', (r) => r.status],
|
|
163
|
-
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
164
140
|
['ID', (r) => r.id],
|
|
165
141
|
])
|
|
166
142
|
console.log()
|