@dooer/dooer-test-env 1.14.0 → 1.15.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/lib/cli.js +36 -21
- package/lib/command/partner.js +34 -57
- package/lib/command/remote-environment.js +281 -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 +60 -12
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()
|
package/lib/remote.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/* global fetch */
|
|
2
|
+
// remote.js — talking to a Dooer environment through its PUBLIC GraphQL endpoint.
|
|
3
|
+
//
|
|
4
|
+
// Everything here goes over ordinary HTTPS to the public facade, so none of it needs kubectl, a VPN or
|
|
5
|
+
// cluster credentials — the same entrypoint the frontends use. (Jimmy 2026-09-04: "use graphql instead
|
|
6
|
+
// since that does not require any k8s, just call the public api endpoint". The k8s-based paths elsewhere
|
|
7
|
+
// in this CLI are expected to move to another mechanism later.)
|
|
8
|
+
//
|
|
9
|
+
// A session is one JWT per environment, kept in the macOS keychain, obtained exactly the way the products
|
|
10
|
+
// obtain it:
|
|
11
|
+
// * password — `loginWithEmail`, what back-office's shared <Login> component calls
|
|
12
|
+
// (@dooer/react-internal-components/dist/components/login/mutations.js)
|
|
13
|
+
// * BankID — `loginWithBankIdV2` + polling `bankIdSessionV2`, the same 1000 ms cadence HQ uses; the
|
|
14
|
+
// `bankIdQRImage` field is the raw animated-QR payload (`bankid.<token>.<seconds>.<hmac>`), which we
|
|
15
|
+
// render as a real QR in the terminal and refresh each poll.
|
|
16
|
+
const { spawnSync } = require('child_process')
|
|
17
|
+
|
|
18
|
+
const KEYCHAIN_ACCOUNT = require('os').userInfo().username
|
|
19
|
+
|
|
20
|
+
// Public GraphQL endpoints, from the environments' own manifests (DOOER_PUBLIC_FACADE_HOST /
|
|
21
|
+
// public-facade route hostnames). `local` is this CLI's own stack on the host.
|
|
22
|
+
const ENVIRONMENTS = {
|
|
23
|
+
local: { url: 'http://localhost:4000/graphql', label: 'local env', production: false },
|
|
24
|
+
staging: { url: 'https://api.s-e032.com/graphql', label: 'staging (s-e032)', production: false },
|
|
25
|
+
production: { url: 'https://api.dooer.com/graphql', label: 'PRODUCTION (l-e033)', production: true },
|
|
26
|
+
}
|
|
27
|
+
// Accept the names used elsewhere in this CLI and in conversation, so nobody has to remember a new word.
|
|
28
|
+
const ALIASES = {
|
|
29
|
+
live: 'production',
|
|
30
|
+
prod: 'production',
|
|
31
|
+
'dooer-production': 'production',
|
|
32
|
+
'l-e033': 'production',
|
|
33
|
+
'dooer-staging': 'staging',
|
|
34
|
+
's-e032': 'staging',
|
|
35
|
+
stage: 'staging',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveEnv(name) {
|
|
39
|
+
const key = ALIASES[name] || name || 'local'
|
|
40
|
+
const env = ENVIRONMENTS[key]
|
|
41
|
+
if (!env) {
|
|
42
|
+
throw new Error(`unknown environment "${name}" — one of: ${Object.keys(ENVIRONMENTS).join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
return { name: key, ...env }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const envNames = () => Object.keys(ENVIRONMENTS)
|
|
48
|
+
|
|
49
|
+
// ── keychain ────────────────────────────────────────────────────────────────
|
|
50
|
+
function sh(cmd, args, input) {
|
|
51
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', input })
|
|
52
|
+
return { status: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const keychainService = (envName) => `dooer-test-env-session:${envName}`
|
|
56
|
+
|
|
57
|
+
// `security -w` prints longer values as lowercase hex (the same behaviour that bit the BankID PFX), so
|
|
58
|
+
// decode when the output is pure even-length hex. A JWT is base64url with dots — never pure hex.
|
|
59
|
+
function tokenGet(envName) {
|
|
60
|
+
const r = sh('security', ['find-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', keychainService(envName), '-w'])
|
|
61
|
+
if (r.status !== 0) return null
|
|
62
|
+
const out = r.stdout
|
|
63
|
+
if (out && out.length % 2 === 0 && /^[0-9a-f]+$/.test(out)) return Buffer.from(out, 'hex').toString('utf8')
|
|
64
|
+
return out || null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -U upserts. The value goes on argv, which is briefly visible in the process list — same trade-off the
|
|
68
|
+
// BankID store already makes on a single-user dev machine, and the keychain is the durable store.
|
|
69
|
+
function tokenSet(envName, token) {
|
|
70
|
+
const r = sh('security', [
|
|
71
|
+
'add-generic-password',
|
|
72
|
+
'-U',
|
|
73
|
+
'-a',
|
|
74
|
+
KEYCHAIN_ACCOUNT,
|
|
75
|
+
'-s',
|
|
76
|
+
keychainService(envName),
|
|
77
|
+
'-D',
|
|
78
|
+
'dooer-test-env session token',
|
|
79
|
+
'-w',
|
|
80
|
+
token,
|
|
81
|
+
])
|
|
82
|
+
if (r.status !== 0) throw new Error(`keychain store failed for ${envName}: ${r.stderr}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tokenDelete(envName) {
|
|
86
|
+
return (
|
|
87
|
+
sh('security', ['delete-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', keychainService(envName)]).status === 0
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── JWT ─────────────────────────────────────────────────────────────────────
|
|
92
|
+
function decodeJwt(token) {
|
|
93
|
+
try {
|
|
94
|
+
const [, payload] = token.split('.')
|
|
95
|
+
return JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'))
|
|
96
|
+
} catch (_) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function expiry(token) {
|
|
102
|
+
const p = decodeJwt(token)
|
|
103
|
+
if (!p || !p.exp) return null
|
|
104
|
+
return { at: new Date(p.exp * 1000), expired: p.exp * 1000 <= Date.now() }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── the token a command should use ──────────────────────────────────────────
|
|
108
|
+
// Stored session first. For `local` only, fall back to minting a service token with the shared dev
|
|
109
|
+
// keypair the local stack is generated with — logging in to your own throwaway stack is pointless
|
|
110
|
+
// friction, and that keypair is worthless anywhere else.
|
|
111
|
+
function tokenFor(envName) {
|
|
112
|
+
const stored = tokenGet(envName)
|
|
113
|
+
if (stored) {
|
|
114
|
+
const exp = expiry(stored)
|
|
115
|
+
if (exp && exp.expired) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`your ${envName} session expired ${exp.at.toISOString().slice(0, 16).replace('T', ' ')} — ` +
|
|
118
|
+
`run: dooer-test-env remote-environment login --env ${envName}`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
return stored
|
|
122
|
+
}
|
|
123
|
+
if (envName === 'local') return require('./api').serviceToken()
|
|
124
|
+
throw new Error(`not logged in to ${envName} — run: dooer-test-env remote-environment login --env ${envName}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── GraphQL ─────────────────────────────────────────────────────────────────
|
|
128
|
+
// One transport for every remote call. Errors come back as a readable sentence rather than a JSON dump.
|
|
129
|
+
async function gql(env, query, variables, { token, anonymous } = {}) {
|
|
130
|
+
const headers = { 'content-type': 'application/json', 'x-dooer-client': 'dooer-test-env@0' }
|
|
131
|
+
if (!anonymous) headers.authorization = `Bearer ${token || tokenFor(env.name)}`
|
|
132
|
+
let res
|
|
133
|
+
try {
|
|
134
|
+
res = await fetch(env.url, { method: 'POST', headers, body: JSON.stringify({ query, variables }) })
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw new Error(`cannot reach ${env.label} at ${env.url}: ${e.message}`)
|
|
137
|
+
}
|
|
138
|
+
const text = await res.text()
|
|
139
|
+
let json
|
|
140
|
+
try {
|
|
141
|
+
json = JSON.parse(text)
|
|
142
|
+
} catch (_) {
|
|
143
|
+
throw new Error(`${env.label} returned ${res.status} (not JSON): ${text.slice(0, 200)}`)
|
|
144
|
+
}
|
|
145
|
+
if (json.errors && json.errors.length) {
|
|
146
|
+
const first = json.errors[0]
|
|
147
|
+
const code = first.code || (first.extensions && first.extensions.code) || ''
|
|
148
|
+
const err = new Error(`${env.label}${code ? ` [${code}]` : ''}: ${first.message}`)
|
|
149
|
+
// Keep the machine-readable parts. A denial carries `extra.resourceType` ("admin"/"partner"/…) and the
|
|
150
|
+
// gateway stamps a transactionId — callers should branch on those rather than re-parse the prose, and
|
|
151
|
+
// the transactionId is what you quote when asking why a call was refused.
|
|
152
|
+
err.code = code
|
|
153
|
+
err.resourceType = (first.extra && first.extra.resourceType) || null
|
|
154
|
+
err.transactionId = (first.extensions && first.extensions.transactionId) || null
|
|
155
|
+
err.graphqlPath = Array.isArray(first.path) ? first.path.join('.') : null
|
|
156
|
+
throw err
|
|
157
|
+
}
|
|
158
|
+
return json.data
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── login: password ─────────────────────────────────────────────────────────
|
|
162
|
+
const LOGIN_WITH_EMAIL = `mutation ($email: String!, $password: String!) {
|
|
163
|
+
loginWithEmail(email: $email, password: $password)
|
|
164
|
+
}`
|
|
165
|
+
|
|
166
|
+
async function loginWithPassword(env, email, password) {
|
|
167
|
+
const data = await gql(env, LOGIN_WITH_EMAIL, { email, password }, { anonymous: true })
|
|
168
|
+
return data.loginWithEmail
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── login: BankID ───────────────────────────────────────────────────────────
|
|
172
|
+
// sePersonnummer is optional: omitted, BankID resolves the identity from whichever device scans the QR.
|
|
173
|
+
const LOGIN_WITH_BANKID = `mutation ($sePersonnummer: SwedishPersonalIdentificationNumber) {
|
|
174
|
+
loginWithBankIdV2(sePersonnummer: $sePersonnummer) {
|
|
175
|
+
id
|
|
176
|
+
bankIdQRImage
|
|
177
|
+
autoStartToken
|
|
178
|
+
status
|
|
179
|
+
hintCode
|
|
180
|
+
}
|
|
181
|
+
}`
|
|
182
|
+
|
|
183
|
+
const POLL_BANKID = `query ($id: ID!) {
|
|
184
|
+
bankIdSessionV2(id: $id) {
|
|
185
|
+
id
|
|
186
|
+
bankIdQRImage
|
|
187
|
+
status
|
|
188
|
+
hintCode
|
|
189
|
+
token
|
|
190
|
+
}
|
|
191
|
+
}`
|
|
192
|
+
|
|
193
|
+
// Human wording for the BankID hint codes that actually show up while waiting.
|
|
194
|
+
const HINTS = {
|
|
195
|
+
outstandingTransaction: 'waiting for the BankID app',
|
|
196
|
+
noClient: 'waiting for the BankID app',
|
|
197
|
+
started: 'BankID app started — choose your ID',
|
|
198
|
+
userSign: 'confirm in the BankID app',
|
|
199
|
+
expiredTransaction: 'the BankID order expired',
|
|
200
|
+
userCancel: 'you cancelled in the BankID app',
|
|
201
|
+
cancelled: 'the order was cancelled',
|
|
202
|
+
startFailed: 'the BankID app did not start',
|
|
203
|
+
certificateErr: 'that BankID cannot be used (revoked, invalid, or too many wrong codes)',
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Start an order and poll it to completion. `onFrame({ qr, status, hintCode })` fires once per poll so the
|
|
207
|
+
// caller can redraw the QR — it changes every second by design (the payload is an HMAC over elapsed
|
|
208
|
+
// seconds), so a static QR goes stale and stops scanning.
|
|
209
|
+
async function loginWithBankId(env, { sePersonnummer, onFrame, intervalMs = 1000, timeoutMs = 180000 }) {
|
|
210
|
+
const start = await gql(env, LOGIN_WITH_BANKID, { sePersonnummer: sePersonnummer || null }, { anonymous: true })
|
|
211
|
+
const session = start.loginWithBankIdV2
|
|
212
|
+
if (onFrame) onFrame({ qr: session.bankIdQRImage, status: session.status, hintCode: session.hintCode })
|
|
213
|
+
|
|
214
|
+
const deadline = Date.now() + timeoutMs
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (Date.now() > deadline) throw new Error('BankID login timed out after 3 minutes')
|
|
217
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
218
|
+
const data = await gql(env, POLL_BANKID, { id: session.id }, { anonymous: true })
|
|
219
|
+
const s = data.bankIdSessionV2
|
|
220
|
+
if (!s) throw new Error('the BankID session disappeared')
|
|
221
|
+
if (onFrame) onFrame({ qr: s.bankIdQRImage, status: s.status, hintCode: s.hintCode })
|
|
222
|
+
if (s.status === 'complete') {
|
|
223
|
+
if (!s.token) throw new Error('BankID completed but returned no token')
|
|
224
|
+
return s.token
|
|
225
|
+
}
|
|
226
|
+
if (s.status === 'failed') {
|
|
227
|
+
throw new Error(`BankID failed: ${HINTS[s.hintCode] || s.hintCode || 'unknown reason'}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── login: BankID via the partner flow (what HQ does) ───────────────────────
|
|
233
|
+
// `loginWithBankIdV2` hardcodes `user_type: 'customer'` in service-accounts
|
|
234
|
+
// (lib/login-with-bank-id.js:199), so it can ONLY ever return the customer account — which is why a
|
|
235
|
+
// BankID login lands on your customer user even when you meant your professional one. HQ authenticates
|
|
236
|
+
// through the PARTNER session instead, which resolves the `hi` user belonging to that partner. It needs
|
|
237
|
+
// the partner's `domain` (e.g. `dooer`, `dooer-devteam`) to know which partner you are signing in to.
|
|
238
|
+
const LOGIN_PARTNER = `mutation ($input: PartnerAuthenticationSessionInput!) {
|
|
239
|
+
partners {
|
|
240
|
+
login(input: $input) {
|
|
241
|
+
id
|
|
242
|
+
bankIdQRImage
|
|
243
|
+
autoStartToken
|
|
244
|
+
status
|
|
245
|
+
bankidProgressStatus
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}`
|
|
249
|
+
|
|
250
|
+
const POLL_PARTNER = `query ($id: ID!) {
|
|
251
|
+
partnerAuthenticationSession(id: $id) {
|
|
252
|
+
session {
|
|
253
|
+
id
|
|
254
|
+
status
|
|
255
|
+
bankidProgressStatus
|
|
256
|
+
bankIdQRImage
|
|
257
|
+
}
|
|
258
|
+
token
|
|
259
|
+
}
|
|
260
|
+
}`
|
|
261
|
+
|
|
262
|
+
// `domain` is '' because that is exactly what HQ sends: its login form submits `{ domain: '' }`
|
|
263
|
+
// (frontend-hq/src/apps/auth/components/partner-login-form.tsx) and never asks which partner you are
|
|
264
|
+
// signing in to — the server resolves that from the BankID identity.
|
|
265
|
+
async function loginWithPartnerBankId(env, { idNumber, onFrame, intervalMs = 1000, timeoutMs = 180000 } = {}) {
|
|
266
|
+
const start = await gql(
|
|
267
|
+
env,
|
|
268
|
+
LOGIN_PARTNER,
|
|
269
|
+
{ input: { domain: '', idNumber: idNumber || null } },
|
|
270
|
+
{ anonymous: true }
|
|
271
|
+
)
|
|
272
|
+
const session = start.partners.login
|
|
273
|
+
if (onFrame) onFrame({ qr: session.bankIdQRImage, hintCode: session.bankidProgressStatus })
|
|
274
|
+
|
|
275
|
+
const deadline = Date.now() + timeoutMs
|
|
276
|
+
for (;;) {
|
|
277
|
+
if (Date.now() > deadline) throw new Error('BankID login timed out after 3 minutes')
|
|
278
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
279
|
+
const data = await gql(env, POLL_PARTNER, { id: session.id }, { anonymous: true })
|
|
280
|
+
const res = data.partnerAuthenticationSession
|
|
281
|
+
if (!res || !res.session) throw new Error('the partner authentication session disappeared')
|
|
282
|
+
const s = res.session
|
|
283
|
+
if (onFrame) onFrame({ qr: s.bankIdQRImage, hintCode: s.bankidProgressStatus })
|
|
284
|
+
// This flow's own status enum: pending | fulfilled | rejected.
|
|
285
|
+
if (s.status === 'fulfilled') {
|
|
286
|
+
if (!res.token) throw new Error('partner login completed but returned no token')
|
|
287
|
+
return res.token
|
|
288
|
+
}
|
|
289
|
+
if (s.status === 'rejected') {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`partner BankID login rejected: ${HINTS[s.bankidProgressStatus] || s.bankidProgressStatus || 'unknown reason'}`
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── become a partner user (back-office "Become user in X") ──────────────────
|
|
298
|
+
// The other way to hold a partner-level session: sign in with your ADMIN account and assume a partner
|
|
299
|
+
// user, rather than authenticating as them with BankID. `PartnerUserMutation.become` →
|
|
300
|
+
// POST /v1/partners/actions/become, which is itself `levels: ['admin']`, and hands back a real token for
|
|
301
|
+
// that partner user.
|
|
302
|
+
const BECOME_PARTNER_USER = `mutation ($pid: ID!, $uid: ID!) {
|
|
303
|
+
partner(id: $pid) { user(userId: $uid) { become { redirectUrl token } } }
|
|
304
|
+
}`
|
|
305
|
+
|
|
306
|
+
async function becomePartnerUser(env, { partnerId, userId }) {
|
|
307
|
+
const data = await gql(env, BECOME_PARTNER_USER, { pid: partnerId, uid: userId })
|
|
308
|
+
return data.partner.user.become
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Becoming replaces the stored session, so the admin token that authorised it is stashed first —
|
|
312
|
+
// otherwise you would have to log in again just to get back to where you were.
|
|
313
|
+
const stashService = (envName) => `${envName}:before-become`
|
|
314
|
+
|
|
315
|
+
function stashToken(envName) {
|
|
316
|
+
const current = tokenGet(envName)
|
|
317
|
+
if (current) tokenSet(stashService(envName), current)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function unstashToken(envName) {
|
|
321
|
+
const previous = tokenGet(stashService(envName))
|
|
322
|
+
if (!previous) return null
|
|
323
|
+
tokenSet(envName, previous)
|
|
324
|
+
tokenDelete(stashService(envName))
|
|
325
|
+
return previous
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── whoami ──────────────────────────────────────────────────────────────────
|
|
329
|
+
// userType is included deliberately: one person has several user rows (customer / hi / admin) and each
|
|
330
|
+
// login route resolves to a different one, so "who am I" is not complete without "as which account".
|
|
331
|
+
const CURRENT_USER = `query { currentUser { id firstName lastName email userType } }`
|
|
332
|
+
|
|
333
|
+
async function currentUser(env, token) {
|
|
334
|
+
const data = await gql(env, CURRENT_USER, {}, token ? { token } : {})
|
|
335
|
+
return data.currentUser
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = {
|
|
339
|
+
ENVIRONMENTS,
|
|
340
|
+
envNames,
|
|
341
|
+
resolveEnv,
|
|
342
|
+
tokenGet,
|
|
343
|
+
tokenSet,
|
|
344
|
+
tokenDelete,
|
|
345
|
+
decodeJwt,
|
|
346
|
+
expiry,
|
|
347
|
+
tokenFor,
|
|
348
|
+
gql,
|
|
349
|
+
loginWithPassword,
|
|
350
|
+
loginWithBankId,
|
|
351
|
+
loginWithPartnerBankId,
|
|
352
|
+
becomePartnerUser,
|
|
353
|
+
stashToken,
|
|
354
|
+
unstashToken,
|
|
355
|
+
currentUser,
|
|
356
|
+
HINTS,
|
|
357
|
+
}
|