d708a85d4c
* feat: cloud backup to Google Drive + full-archive all-scope Adds a cloud-backup extension that uploads a full-company backup ZIP to the user's own Google Drive via OAuth (drive.file scope only). Refresh tokens are AES-256-GCM encrypted before being stored in extension_data. The full-archive export gains a scope=all mode for whole-company backups (per-period SIE under sie/, per-period rapporter/ subfolders, flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size guard short-circuits generation before the platform response limit. Also fixes a latent bug in lib/core/audit/audit-service.ts where the parameter was named userId while the query filtered by company_id; the audit-trail API route was passing user.id so audit queries returned empty unless user and company shared a UUID. Drive-by: scope the dashboard "fresh start" localStorage key per companyId so dismissing the setup checklist in one company no longer carries over to others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review comments on cloud backup + archive export - Extend audit trail to_date to end-of-day so last-day entries aren't silently excluded from period-scoped archives. - Apply 413 size-limit guard regardless of include_documents, using the overhead-only figure when documents are excluded. - Use crypto.randomUUID() for Drive multipart boundary to eliminate any collision risk with ZIP payload bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: migrate legacy setup-gate localStorage keys on dashboard Users who previously dismissed the setup checklist via the old global erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after the switch to a company-scoped key. Fall back to the legacy keys on read and migrate them to the scoped key on first hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update customer email handling and anonymization rules in supportmail-to-ticket skill * test: update audit trail to_date expectation for end-of-day timestamp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
3.9 KiB
TypeScript
131 lines
3.9 KiB
TypeScript
/**
|
|
* Minimal Google OAuth 2.0 client for the cloud-backup extension.
|
|
*
|
|
* Scope: `drive.file` — app-created files only, not the user's full Drive.
|
|
* Access type: `offline` — returns a refresh token on first consent.
|
|
* Prompt: `consent` — forces the consent screen so the refresh token is
|
|
* re-issued even if the user has previously authorised the app.
|
|
*/
|
|
|
|
const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
|
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
|
const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo'
|
|
|
|
export interface OAuthEnv {
|
|
clientId: string
|
|
clientSecret: string
|
|
redirectUri: string
|
|
}
|
|
|
|
export function getOAuthEnv(origin: string): OAuthEnv {
|
|
const clientId = process.env.GOOGLE_CLIENT_ID
|
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
|
if (!clientId || !clientSecret) {
|
|
throw new Error(
|
|
'Google OAuth is not configured: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'
|
|
)
|
|
}
|
|
return {
|
|
clientId,
|
|
clientSecret,
|
|
redirectUri: `${origin}/api/extensions/ext/cloud-backup/oauth/callback`,
|
|
}
|
|
}
|
|
|
|
export function buildAuthorizationUrl(env: OAuthEnv, state: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: env.clientId,
|
|
redirect_uri: env.redirectUri,
|
|
response_type: 'code',
|
|
scope: `openid email ${DRIVE_SCOPE}`,
|
|
access_type: 'offline',
|
|
prompt: 'consent',
|
|
include_granted_scopes: 'true',
|
|
state,
|
|
})
|
|
return `${AUTH_ENDPOINT}?${params.toString()}`
|
|
}
|
|
|
|
export interface TokenExchangeResult {
|
|
access_token: string
|
|
refresh_token: string
|
|
expires_in: number
|
|
id_token?: string
|
|
}
|
|
|
|
export async function exchangeCodeForTokens(
|
|
env: OAuthEnv,
|
|
code: string
|
|
): Promise<TokenExchangeResult> {
|
|
const body = new URLSearchParams({
|
|
code,
|
|
client_id: env.clientId,
|
|
client_secret: env.clientSecret,
|
|
redirect_uri: env.redirectUri,
|
|
grant_type: 'authorization_code',
|
|
})
|
|
const res = await fetch(TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
})
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(`Google token exchange failed: ${res.status} ${errText}`)
|
|
}
|
|
const json = (await res.json()) as TokenExchangeResult
|
|
if (!json.refresh_token) {
|
|
throw new Error(
|
|
'No refresh token returned — Google only issues one on first consent. ' +
|
|
'Revoke the app at myaccount.google.com/permissions and try again.'
|
|
)
|
|
}
|
|
return json
|
|
}
|
|
|
|
export interface AccessTokenResult {
|
|
access_token: string
|
|
expires_in: number
|
|
}
|
|
|
|
export async function refreshAccessToken(
|
|
env: OAuthEnv,
|
|
refreshToken: string
|
|
): Promise<AccessTokenResult> {
|
|
const body = new URLSearchParams({
|
|
client_id: env.clientId,
|
|
client_secret: env.clientSecret,
|
|
refresh_token: refreshToken,
|
|
grant_type: 'refresh_token',
|
|
})
|
|
const res = await fetch(TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
})
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
|
|
}
|
|
return (await res.json()) as AccessTokenResult
|
|
}
|
|
|
|
export async function revokeToken(token: string): Promise<void> {
|
|
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
})
|
|
}
|
|
|
|
export async function fetchUserEmail(accessToken: string): Promise<string> {
|
|
const res = await fetch(USERINFO_ENDPOINT, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch Google user info: ${res.status}`)
|
|
}
|
|
const json = (await res.json()) as { email?: string }
|
|
return json.email || 'unknown@google'
|
|
}
|