api: pair the mobile app with device tokens
MB1 (spec 13). Decision 0005: our own Android/iPhone app, Expo in the mobile repo, user portal only, no push in v1. - mobile_devices (migration 0013): one-time pairing codes from My settings (QR or server + code, 10 minutes), traded by the app for an spm_ device token that acts as its user and never as an admin - POST /api/me/mobile-devices/pair (web session only), POST /api/mobile/pair, GET/DELETE /api/me/mobile-devices - signing out everywhere (password change or reset, disabling) removes paired phones too - My settings: Mobile app section with the QR code and paired phones - mobile/ (its own repo) ignored here
This commit is contained in:
1 parent
adb6d52ddf
commit
0cdac0bcdc
26 files changed
+5340
-18
No files matched your search
@@ -23,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
|
||||
import { outboundRouteRoutes } from './routes/outbound.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
import { meRoutes } from './routes/me.ts';
|
||||
import { mobileRoutes } from './routes/mobile.ts';
|
||||
import { soundRoutes } from './routes/sounds.ts';
|
||||
import { ringGroupRoutes } from './routes/ringgroups.ts';
|
||||
import { queueRoutes } from './routes/queues.ts';
|
||||
@@ -159,6 +160,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(mobileRoutes);
|
||||
app.register(contactRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
|
||||
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { mobileDevices, users } from '../db/schema.ts';
|
||||
import { generateToken, hashToken } from './tokens.ts';
|
||||
|
||||
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
|
||||
/** No 0/O/1/I: people type it from a screen. */
|
||||
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
export const PAIR_CODE_TTL_MS = 10 * 60_000;
|
||||
|
||||
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
|
||||
|
||||
export function generatePairCode(): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The device's (active) user, or null for an unknown, removed or malformed token. */
|
||||
export async function getDeviceUser(db: Db, token: string) {
|
||||
if (!DEVICE_TOKEN_RE.test(token)) return null;
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
|
||||
if (!row) return null;
|
||||
await db
|
||||
.update(mobileDevices)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(mobileDevices.id, row.id));
|
||||
return { user: row.user, deviceId: row.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
|
||||
* or whose user was disabled since.
|
||||
*/
|
||||
export async function pairDevice(
|
||||
db: Db,
|
||||
code: string,
|
||||
device: { name: string; platform: 'android' | 'ios' },
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(mobileDevices.pairCodeHash, hashToken(code)),
|
||||
gt(mobileDevices.pairExpiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
const token = generateDeviceToken();
|
||||
// The code is cleared in the same statement: two apps racing with one code can't both win.
|
||||
const [won] = await db
|
||||
.update(mobileDevices)
|
||||
.set({
|
||||
...device,
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
tokenHash: hashToken(token),
|
||||
pairCodeHash: null,
|
||||
pairExpiresAt: null,
|
||||
})
|
||||
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
|
||||
.returning({ id: mobileDevices.id });
|
||||
return won ? { token, deviceId: won.id, user: row.user } : null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
|
||||
import { getDeviceUser } from './devices.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
@@ -15,22 +16,28 @@ declare module 'fastify' {
|
||||
sessionId: string | null;
|
||||
/** Set when the request came with an API token instead of a session. */
|
||||
apiTokenId?: string;
|
||||
/** Set when the request came from a paired phone (13 §2). */
|
||||
mobileDeviceId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
|
||||
* Enforces must_change_password outside /api/auth/*.
|
||||
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
|
||||
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
|
||||
*/
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
|
||||
if (!t) throw unauthorized();
|
||||
req.user = t.user;
|
||||
const raw = auth.slice(7).trim();
|
||||
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
|
||||
const t = d ? null : await getTokenUser(req.server.db, raw);
|
||||
if (!d && !t) throw unauthorized();
|
||||
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
|
||||
req.user = d ? { ...d.user, role: 'user' } : t!.user;
|
||||
req.sessionId = null;
|
||||
req.apiTokenId = t.tokenId;
|
||||
if (t.user.mustChangePassword)
|
||||
if (d) req.mobileDeviceId = d.deviceId;
|
||||
else req.apiTokenId = t!.tokenId;
|
||||
if (req.user.mustChangePassword)
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
import { mobileDevices, sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
const DAY = 86_400_000;
|
||||
@@ -83,8 +83,12 @@ export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
/** Logs a user out everywhere except (optionally) the current session. */
|
||||
/**
|
||||
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
|
||||
* a password change, a reset, or disabling them).
|
||||
*/
|
||||
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
|
||||
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "mobile_devices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text,
|
||||
"platform" text,
|
||||
"token_prefix" text,
|
||||
"token_hash" text,
|
||||
"pair_code_hash" text,
|
||||
"pair_expires_at" timestamp with time zone,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
|
||||
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,6 +92,13 @@
|
||||
"when": 1792951905226,
|
||||
"tag": "0012_sso",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1793052019332,
|
||||
"tag": "0013_mobile_devices",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -134,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
|
||||
export const mobileDevices = pgTable(
|
||||
'mobile_devices',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text(),
|
||||
platform: text(),
|
||||
tokenPrefix: text(),
|
||||
tokenHash: text().unique(),
|
||||
pairCodeHash: text().unique(),
|
||||
pairExpiresAt: tstz(),
|
||||
lastUsedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
|
||||
],
|
||||
);
|
||||
|
||||
// ───────────── extensions ─────────────
|
||||
export const outboundClass = pgEnum('outbound_class', [
|
||||
'internal',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a
|
||||
// device token; the person's paired phones, and removing one.
|
||||
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
MobilePairSchema,
|
||||
type ListResponse,
|
||||
type MobileDeviceDto,
|
||||
type MobilePairCode,
|
||||
type MobilePairResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts';
|
||||
import { SLOW, requireAuth } from '../auth/guards.ts';
|
||||
import { hashToken } from '../auth/tokens.ts';
|
||||
import { mobileDevices } from '../db/schema.ts';
|
||||
import { forbidden, HttpError, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
export async function mobileRoutes(app: FastifyInstance) {
|
||||
const db = app.db;
|
||||
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
|
||||
|
||||
app.post(
|
||||
'/api/me/mobile-devices/pair',
|
||||
{ preHandler: requireAuth, config: SLOW },
|
||||
async (req): Promise<MobilePairCode> => {
|
||||
// Only a person at the web page: no token (a stolen phone can't make itself a spare).
|
||||
if (!req.sessionId) throw forbidden('Pair a phone from the web page');
|
||||
const userId = req.user!.id;
|
||||
await db
|
||||
.delete(mobileDevices)
|
||||
.where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash)));
|
||||
const code = generatePairCode();
|
||||
const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS);
|
||||
const [d] = await db
|
||||
.insert(mobileDevices)
|
||||
.values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt })
|
||||
.returning({ id: mobileDevices.id });
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'create',
|
||||
entityType: 'mobile_device',
|
||||
entityId: d!.id,
|
||||
summary: 'Made a code to pair a phone',
|
||||
});
|
||||
const server = `https://${app.config.PUBLIC_HOSTNAME}`;
|
||||
const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`;
|
||||
return {
|
||||
code,
|
||||
server,
|
||||
link,
|
||||
qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** Public: the code is the credential. The only time the device token is shown. */
|
||||
app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise<MobilePairResult> => {
|
||||
const { code, ...device } = MobilePairSchema.parse(req.body);
|
||||
const paired = await pairDevice(db, code, device);
|
||||
if (!paired)
|
||||
throw new HttpError(
|
||||
404,
|
||||
'pair_code_invalid',
|
||||
'That code is wrong or expired. Make a new one.',
|
||||
);
|
||||
await audit(db, {
|
||||
actorUserId: paired.user.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'mobile_device',
|
||||
entityId: paired.deviceId,
|
||||
summary: `Paired ${device.platform} phone ${device.name}`,
|
||||
});
|
||||
return {
|
||||
token: paired.token,
|
||||
user: { name: paired.user.displayName, email: paired.user.email },
|
||||
};
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/me/mobile-devices',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ListResponse<MobileDeviceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mobileDevices)
|
||||
.where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash)))
|
||||
.orderBy(mobileDevices.createdAt);
|
||||
return {
|
||||
items: rows.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name!,
|
||||
platform: d.platform as MobileDeviceDto['platform'],
|
||||
tokenPrefix: d.tokenPrefix!,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
lastUsedAt: d.lastUsedAt?.toISOString() ?? null,
|
||||
current: d.id === req.mobileDeviceId,
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** Removes one of my phones; the app's Sign out removes itself. */
|
||||
app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [d] = await db
|
||||
.delete(mobileDevices)
|
||||
.where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id)))
|
||||
.returning();
|
||||
if (!d) throw notFound('Phone not found');
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'delete',
|
||||
entityType: 'mobile_device',
|
||||
entityId: id,
|
||||
summary: `Removed phone ${d.name ?? '(not paired)'}`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// MB1: pairing the mobile app and its device tokens (13 §2, §4).
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { auditLog, mobileDevices, users } from '../src/db/schema.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('mobile app pairing', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let danId: string;
|
||||
const withHeaders =
|
||||
(headers: Record<string, string>): Req =>
|
||||
(method, url, payload) =>
|
||||
t.app.inject({ method: method as 'GET', url, payload: payload as object, headers });
|
||||
const bearer = (token: string) => withHeaders({ authorization: `Bearer ${token}` });
|
||||
// The app sends X-Requested-With on its one call without a token (CSRF hook, 04 §2.2).
|
||||
const anon = withHeaders(H);
|
||||
const pair = (code: string, name = 'Pixel 9') =>
|
||||
anon('POST', '/api/mobile/pair', { code, name, platform: 'android' });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = withHeaders({ ...H, cookie: await t.login(ADMIN.email, ADMIN.password) });
|
||||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
dan = withHeaders({ ...H, cookie: await t.login('dan@example.com', 'dan-password-123') });
|
||||
// GET /api/me needs an extension.
|
||||
for (const [number, userId] of [
|
||||
['101', danId],
|
||||
['102', (await t.db.select().from(users).where(eq(users.email, ADMIN.email)))[0]!.id],
|
||||
])
|
||||
await admin('POST', '/api/extensions', { number, name: `Ext ${number}`, userId });
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
let token: string;
|
||||
let deviceId: string;
|
||||
|
||||
it('pairs a phone once with a code from the web page', async () => {
|
||||
const r = await dan('POST', '/api/me/mobile-devices/pair');
|
||||
expect(r.statusCode).toBe(200);
|
||||
const c = r.json();
|
||||
expect(c.code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{12}$/);
|
||||
expect(c.server).toBe('https://pbx.test');
|
||||
expect(c.link).toBe(
|
||||
`selfphoneng://pair?server=${encodeURIComponent('https://pbx.test')}&code=${c.code}`,
|
||||
);
|
||||
expect(c.qr).toMatch(/^data:image\/png;base64,/);
|
||||
expect(new Date(c.expiresAt).getTime() - Date.now()).toBeGreaterThan(9 * 60_000);
|
||||
|
||||
// Typed by hand: lower case and spaces are fine.
|
||||
const typed = `${c.code.slice(0, 4)} ${c.code.slice(4)}`.toLowerCase();
|
||||
const p = await pair(typed);
|
||||
expect(p.statusCode).toBe(200);
|
||||
expect(p.json().user).toEqual({ name: expect.any(String), email: 'dan@example.com' });
|
||||
token = p.json().token;
|
||||
expect(token).toMatch(/^spm_[A-Za-z0-9]{40}$/);
|
||||
|
||||
expect((await pair(c.code)).json().error.code).toBe('pair_code_invalid'); // used
|
||||
const rows = await t.db.select().from(mobileDevices);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.tokenHash).not.toContain(token.slice(4)); // stored hashed
|
||||
expect(rows[0]!.pairCodeHash).toBeNull();
|
||||
deviceId = rows[0]!.id;
|
||||
});
|
||||
|
||||
it('the device token acts as its user', async () => {
|
||||
const me = bearer(token);
|
||||
expect((await me('GET', '/api/me')).statusCode).toBe(200);
|
||||
// No X-Requested-With needed with a Bearer token.
|
||||
expect((await me('PATCH', '/api/me', { dnd: true })).statusCode).toBe(200);
|
||||
const list = (await me('GET', '/api/me/mobile-devices')).json().items;
|
||||
expect(list).toEqual([
|
||||
{
|
||||
id: deviceId,
|
||||
name: 'Pixel 9',
|
||||
platform: 'android',
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: expect.any(String),
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/mobile-devices')).json().items[0].current).toBe(false);
|
||||
// A phone can't make itself a spare.
|
||||
expect((await me('POST', '/api/me/mobile-devices/pair')).statusCode).toBe(403);
|
||||
expect((await bearer(`spm_${'x'.repeat(40)}`)('GET', '/api/me')).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("an admin's phone is not an admin", async () => {
|
||||
const code = (await admin('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const phone = bearer((await pair(code, 'iPhone')).json().token);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
|
||||
expect((await phone('GET', '/api/users')).statusCode).toBe(403);
|
||||
expect((await phone('GET', '/api/api-tokens')).statusCode).toBe(403);
|
||||
expect((await admin('GET', '/api/users')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses expired codes, bad input, and replaces an unused code', async () => {
|
||||
const first = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const second = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
expect((await pair(first)).statusCode).toBe(404); // replaced
|
||||
await t.db
|
||||
.update(mobileDevices)
|
||||
.set({ pairExpiresAt: new Date(Date.now() - 1000) })
|
||||
.where(eq(mobileDevices.userId, danId));
|
||||
expect((await pair(second)).json().error.code).toBe('pair_code_invalid');
|
||||
const bad = await anon('POST', '/api/mobile/pair', {
|
||||
code: second,
|
||||
name: 'x',
|
||||
platform: 'windows',
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('a disabled user loses their phones and can’t pair one', async () => {
|
||||
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
await t.db.update(users).set({ isActive: false }).where(eq(users.id, danId));
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
|
||||
expect((await pair(code)).statusCode).toBe(404);
|
||||
await t.db.update(users).set({ isActive: true }).where(eq(users.id, danId));
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('removing a device ends its token; only your own', async () => {
|
||||
const adminPhone = (await t.db.select().from(mobileDevices)).find((d) => d.name === 'iPhone')!;
|
||||
expect(
|
||||
(await bearer(token)('DELETE', `/api/me/mobile-devices/${adminPhone.id}`)).statusCode,
|
||||
).toBe(404);
|
||||
expect((await bearer(token)('DELETE', `/api/me/mobile-devices/${deviceId}`)).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
|
||||
const actions = (await t.db.select().from(auditLog))
|
||||
.filter((a) => a.entityType === 'mobile_device')
|
||||
.map((a) => a.action);
|
||||
expect(actions).toContain('create');
|
||||
expect(actions).toContain('delete');
|
||||
});
|
||||
|
||||
it('changing the password removes every paired phone', async () => {
|
||||
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const phone = bearer((await pair(code)).json().token);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
|
||||
const r = await dan('POST', '/api/auth/change-password', {
|
||||
currentPassword: 'dan-password-123',
|
||||
newPassword: 'a-new-dan-password-456',
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(401);
|
||||
expect((await dan('GET', '/api/me/mobile-devices')).json().items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 44 tables', async () => {
|
||||
it('creates all 45 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 44 });
|
||||
expect(r.rows[0]).toEqual({ n: 45 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
ContactShare,
|
||||
ContactShareDto,
|
||||
MyCallDto,
|
||||
MobileDeviceDto,
|
||||
MobilePairCode,
|
||||
RecordingDto,
|
||||
AuthUser,
|
||||
DidCreate,
|
||||
@@ -303,6 +305,10 @@ export const meApi = {
|
||||
disconnectContrack: () => api<MeDto>('DELETE', '/api/me/contrack'),
|
||||
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
|
||||
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
|
||||
pairPhone: () => api<MobilePairCode>('POST', '/api/me/mobile-devices/pair'),
|
||||
phones: () =>
|
||||
api<ListResponse<MobileDeviceDto>>('GET', '/api/me/mobile-devices').then((r) => r.items),
|
||||
removePhone: (id: string) => api<{ ok: true }>('DELETE', `/api/me/mobile-devices/${id}`),
|
||||
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
|
||||
setQueuePaused: (queueId: string, paused: boolean) =>
|
||||
api<{ ok: true }>('POST', `/api/me/queues/${queueId}/${paused ? 'pause' : 'unpause'}`),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
|
||||
// report email, my Contrack account (11 §4.3).
|
||||
// report email, my Contrack account (11 §4.3), pairing the mobile app (13 §2).
|
||||
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
@@ -70,6 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) {
|
||||
</h2>
|
||||
<PhoneAppSetup load={meApi.softphoneQr} />
|
||||
</section>
|
||||
<MobileApp />
|
||||
<SignInCard />
|
||||
</div>
|
||||
);
|
||||
@@ -411,3 +412,88 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pairing our own app (13 §2): a one-time QR code, and the phones paired so far. */
|
||||
function MobileApp() {
|
||||
const qc = useQueryClient();
|
||||
const pair = useMutation({ mutationFn: meApi.pairPhone });
|
||||
// While a code is up, look for the phone that uses it, then put the code away.
|
||||
const phones = useQuery({
|
||||
queryKey: ['me', 'phones'],
|
||||
queryFn: meApi.phones,
|
||||
refetchInterval: pair.data ? 3000 : false,
|
||||
});
|
||||
const [before, setBefore] = useState(0);
|
||||
const paired = pair.data && (phones.data?.length ?? 0) > before;
|
||||
useEffect(() => {
|
||||
if (paired) pair.reset();
|
||||
}, [paired, pair]);
|
||||
const remove = useMutation({
|
||||
mutationFn: meApi.removePhone,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['me', 'phones'] }),
|
||||
});
|
||||
return (
|
||||
<section aria-labelledby="mob-h" className="flex flex-col gap-3 text-sm">
|
||||
<h2 id="mob-h" className="text-lg font-semibold">
|
||||
Mobile app
|
||||
</h2>
|
||||
<p>
|
||||
The SelfPhoneNG app for Android and iPhone signs in by pairing: tap{' '}
|
||||
<em>Pair with a server</em> in the app and point the camera at the code below. It rings only
|
||||
while the app is open.
|
||||
</p>
|
||||
{phones.data && phones.data.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{phones.data.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-3">
|
||||
<span>
|
||||
{d.name}{' '}
|
||||
<span className="text-zinc-500">
|
||||
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
|
||||
{new Date(d.createdAt).toLocaleDateString()}
|
||||
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`})
|
||||
</span>
|
||||
</span>
|
||||
<Button onClick={() => remove.mutate(d.id)} disabled={remove.isPending}>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!pair.data ? (
|
||||
<Button
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
setBefore(phones.data?.length ?? 0);
|
||||
pair.mutate();
|
||||
}}
|
||||
disabled={pair.isPending}
|
||||
>
|
||||
Pair a phone
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<img
|
||||
src={pair.data.qr}
|
||||
alt="Pairing QR code for the mobile app"
|
||||
width={280}
|
||||
height={280}
|
||||
className="rounded bg-white"
|
||||
/>
|
||||
<p>
|
||||
Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
|
||||
code <span className="font-mono">{pair.data.code}</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
|
||||
gets into your account: don't share it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(pair.error ?? phones.error ?? remove.error) && (
|
||||
<Alert>{(pair.error ?? phones.error ?? remove.error)!.message}</Alert>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user