Skip to content

Commit 5354445

Browse files
committed
feat(api): add organization membership verification for device check-in
1 parent dc9954a commit 5354445

8 files changed

Lines changed: 101 additions & 35 deletions

File tree

apps/portal/src/app/api/device-agent/check-in/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ export async function POST(req: NextRequest) {
5656
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
5757
}
5858

59+
// Verify the user is still an active member of the device's organization
60+
const member = await db.member.findFirst({
61+
where: {
62+
userId: session.user.id,
63+
organizationId: device.organizationId,
64+
deactivated: false,
65+
},
66+
});
67+
68+
if (!member) {
69+
return NextResponse.json({ error: 'Not an active member of this organization' }, { status: 403 });
70+
}
71+
5972
// Delete old checks for the same types and create new ones
6073
const checkTypes = checks.map((c) => c.checkType);
6174

apps/portal/src/app/api/device-agent/register/route.ts

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,35 +51,80 @@ export async function POST(req: NextRequest) {
5151
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 });
5252
}
5353

54-
// Upsert device: if same serial number + org exists, update; otherwise create
55-
const device = await db.device.upsert({
56-
where: {
57-
serialNumber_organizationId: {
58-
serialNumber: serialNumber ?? '',
54+
// Branch on serialNumber to avoid collisions for serial-less devices.
55+
// PostgreSQL treats NULLs as distinct in unique constraints, so devices
56+
// without a serial number can safely coexist in the same org.
57+
let device;
58+
59+
if (serialNumber) {
60+
// Serial number present — upsert on the unique (serialNumber, organizationId) key
61+
device = await db.device.upsert({
62+
where: {
63+
serialNumber_organizationId: {
64+
serialNumber,
65+
organizationId,
66+
},
67+
},
68+
update: {
69+
name,
70+
hostname,
71+
platform,
72+
osVersion,
73+
hardwareModel,
74+
agentVersion,
75+
userId: session.user.id,
76+
},
77+
create: {
78+
name,
79+
hostname,
80+
platform,
81+
osVersion,
82+
serialNumber,
83+
hardwareModel,
84+
agentVersion,
85+
userId: session.user.id,
5986
organizationId,
6087
},
61-
},
62-
update: {
63-
name,
64-
hostname,
65-
platform,
66-
osVersion,
67-
hardwareModel,
68-
agentVersion,
69-
userId: session.user.id,
70-
},
71-
create: {
72-
name,
73-
hostname,
74-
platform,
75-
osVersion,
76-
serialNumber,
77-
hardwareModel,
78-
agentVersion,
79-
userId: session.user.id,
80-
organizationId,
81-
},
82-
});
88+
});
89+
} else {
90+
// No serial number — find by hostname + userId + org (same user re-registering
91+
// the same machine), or create a new record with serialNumber = null.
92+
const existing = await db.device.findFirst({
93+
where: {
94+
hostname,
95+
userId: session.user.id,
96+
organizationId,
97+
serialNumber: null,
98+
},
99+
});
100+
101+
if (existing) {
102+
device = await db.device.update({
103+
where: { id: existing.id },
104+
data: {
105+
name,
106+
platform,
107+
osVersion,
108+
hardwareModel,
109+
agentVersion,
110+
},
111+
});
112+
} else {
113+
device = await db.device.create({
114+
data: {
115+
name,
116+
hostname,
117+
platform,
118+
osVersion,
119+
serialNumber: null,
120+
hardwareModel,
121+
agentVersion,
122+
userId: session.user.id,
123+
organizationId,
124+
},
125+
});
126+
}
127+
}
83128

84129
return NextResponse.json({ deviceId: device.id });
85130
} catch (error) {

apps/portal/src/app/api/download-agent/token/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server';
66
import type { DownloadAgentRequest, SupportedOS } from '../types';
77
import { detectOSFromUserAgent, validateMemberAndOrg } from '../utils';
88

9-
const SUPPORTED_OSES: SupportedOS[] = ['macos', 'macos-intel', 'windows'];
9+
const SUPPORTED_OSES: SupportedOS[] = ['macos', 'macos-intel', 'windows', 'linux'];
1010

1111
const isSupportedOS = (value: unknown): value is SupportedOS =>
1212
typeof value === 'string' && SUPPORTED_OSES.includes(value as SupportedOS);

apps/portal/src/app/api/download-agent/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type SupportedOS = 'macos' | 'windows' | 'macos-intel';
1+
export type SupportedOS = 'macos' | 'windows' | 'macos-intel' | 'linux';
22

33
export interface DownloadAgentRequest {
44
orgId: string;

apps/portal/src/app/api/download-agent/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import type { SupportedOS } from './types';
66
* Detects the operating system (and for macOS, the CPU architecture) from a User-Agent string.
77
*
88
* Returns:
9+
* - 'linux' for Linux OS (excluding Android)
910
* - 'windows' for Windows OS
1011
* - 'macos' for Apple Silicon (ARM-based) Macs
1112
* - 'macos-intel' for Intel-based Macs
1213
*
1314
* Examples of User-Agent strings:
15+
* - Linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
1416
* - Windows: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
1517
* - macOS (Intel): "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
1618
* - macOS (Apple Silicon): "Mozilla/5.0 (Macintosh; ARM Mac OS X 11_2_3) AppleWebKit/537.36"
@@ -30,6 +32,11 @@ export function detectOSFromUserAgent(userAgent: string | null): SupportedOS | n
3032

3133
const ua = userAgent.toLowerCase();
3234

35+
// Check Linux before Windows/Mac — exclude Android which also contains 'linux'
36+
if (ua.includes('linux') && !ua.includes('android')) {
37+
return 'linux';
38+
}
39+
3340
if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) {
3441
return 'windows';
3542
}

packages/device-agent/src/main/auth.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,7 @@ export async function performLogin(deviceInfo: DeviceInfo): Promise<StoredAuth |
7979
const authData = await extractAuthAndRegisterAll(deviceInfo, authWindow);
8080
if (authData) {
8181
setAuth(authData);
82-
log(
83-
`Auth complete: ${authData.organizations.length} org(s) registered`,
84-
);
82+
log(`Auth complete: ${authData.organizations.length} org(s) registered`);
8583
finish(authData);
8684
}
8785
// If null, don't close — might be intermediate navigation
@@ -122,8 +120,7 @@ async function extractAuthAndRegisterAll(
122120
const cookies = await session.defaultSession.cookies.get({ url: portalUrl });
123121
const sessionCookie = cookies.find(
124122
(c) =>
125-
c.name === 'better-auth.session_token' ||
126-
c.name === '__Secure-better-auth.session_token',
123+
c.name === 'better-auth.session_token' || c.name === '__Secure-better-auth.session_token',
127124
);
128125

129126
if (!sessionCookie) {
@@ -236,6 +233,7 @@ async function extractAuthAndRegisterAll(
236233

237234
return {
238235
sessionToken,
236+
cookieName: sessionCookie.name,
239237
userId,
240238
organizations: registrations,
241239
};

packages/device-agent/src/main/reporter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export async function reportCheckResults(checks: CheckResult[]): Promise<ReportR
2222
}
2323

2424
const portalUrl = getPortalUrl();
25-
const cookieHeader = `better-auth.session_token=${auth.sessionToken}`;
25+
const cookieName = auth.cookieName ?? 'better-auth.session_token';
26+
const cookieHeader = `${cookieName}=${auth.sessionToken}`;
2627

2728
let allSucceeded = true;
2829
let anyNonCompliant = false;

packages/device-agent/src/shared/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export interface OrgRegistration {
7373
/** Stored authentication data — supports multiple organizations */
7474
export interface StoredAuth {
7575
sessionToken: string;
76+
/** The cookie name used by the server (e.g. 'better-auth.session_token' or '__Secure-better-auth.session_token') */
77+
cookieName: string;
7678
userId: string;
7779
organizations: OrgRegistration[];
7880
}

0 commit comments

Comments
 (0)