diff --git a/src/commands/Environment/createEnvironment.ts b/src/commands/Environment/createEnvironment.ts index 2fa1661..df6e134 100644 --- a/src/commands/Environment/createEnvironment.ts +++ b/src/commands/Environment/createEnvironment.ts @@ -8,7 +8,7 @@ export class CreateEnvironment implements Command { public readonly id = "vercel.createEnvironment"; constructor(private readonly vercel: VercelManager) {} async execute() { - if (!((await this.vercel.auth) && this.vercel.selectedProject)) return; + if (!this.vercel.selectedProject) return; const envlist = (await this.vercel.env.getEnvList())?.map(x => x.key); if (!envlist) throw new Error("Failed to get environment list"); diff --git a/src/commands/Environment/removeEnvironment.ts b/src/commands/Environment/removeEnvironment.ts index 6116281..7080bb4 100644 --- a/src/commands/Environment/removeEnvironment.ts +++ b/src/commands/Environment/removeEnvironment.ts @@ -19,7 +19,7 @@ export class RemoveEnvironment implements Command { public readonly id = "vercel.removeEnvironment"; constructor(private readonly vercel: VercelManager) {} async execute(command?: { envId: string; key: string }) { - if (!((await this.vercel.auth) && this.vercel.selectedProject)) return; + if (!this.vercel.selectedProject) return; //> Get information from arguments if given let id: string | undefined = command?.envId; let key: string | undefined = command?.key; diff --git a/src/commands/Environment/setEnvironment.ts b/src/commands/Environment/setEnvironment.ts index 32e8f2a..1b448a8 100644 --- a/src/commands/Environment/setEnvironment.ts +++ b/src/commands/Environment/setEnvironment.ts @@ -26,7 +26,8 @@ export class SetEnvironment implements Command { key: string; editing: "TARGETS" | "VALUE"; }) { - if (!((await this.vercel.auth) && this.vercel.selectedProject)) return; + if (!this.vercel.selectedProject) return; + if (!(await this.vercel.loggedIn())) return; //> Get info from arguments if given let id: string | undefined = command?.id; diff --git a/src/features/EnvironmentProvider.ts b/src/features/EnvironmentProvider.ts index 25fa096..4f614eb 100644 --- a/src/features/EnvironmentProvider.ts +++ b/src/features/EnvironmentProvider.ts @@ -98,7 +98,8 @@ export class EnvironmentProvider ]; return items; } - if (!this.vercel.selectedProject || !(await this.vercel.auth)) return []; + if (!this.vercel.selectedProject || !(await this.vercel.loggedIn())) + return []; const items: vscode.TreeItem[] = [new CreateEnvironment()]; const res = await this.vercel.env.getAll(); if (res.length > 0) { diff --git a/src/features/StatusBar.ts b/src/features/StatusBar.ts index 97ce3d0..197877c 100644 --- a/src/features/StatusBar.ts +++ b/src/features/StatusBar.ts @@ -44,7 +44,7 @@ export class StatusBar { } public async updateStatus(): Promise { - if (!(await this.vercel.auth)) { + if (!(await this.vercel.loggedIn())) { this.text = "Login"; this.tooltip = "Click to login"; return; diff --git a/src/features/TokenManager.ts b/src/features/TokenManager.ts index f038e18..920607c 100644 --- a/src/features/TokenManager.ts +++ b/src/features/TokenManager.ts @@ -1,9 +1,11 @@ import * as vscode from "vscode"; import path from "path"; import { parseJsonObject } from "../utils/jsonParse"; +import type { OauthResult } from "../utils/oauth"; export class TokenManager { private readonly authKey = "vercel_token"; + private readonly teamIdKey = "vercel_team_id"; private readonly projectKey = "vercel_selected_project"; private readonly onAuthStateChanged: (state: boolean) => void; @@ -80,14 +82,21 @@ export class TokenManager { }); } - setAuth(token: string | undefined) { + async setAuth(token: OauthResult | undefined) { this.onAuthStateChanged(!!token); - if (token === undefined) return this.secrets.delete(this.authKey); - return this.secrets.store(this.authKey, token); + if (token?.accessToken) + await this.secrets.store(this.authKey, token.accessToken); + else await this.secrets.delete(this.authKey); + + if (token?.teamId) await this.secrets.store(this.teamIdKey, token.teamId); + else await this.secrets.delete(this.authKey); } - getAuth(): Thenable { - return this.secrets.get(this.authKey); + async getAuth(): Promise { + const accessToken = await this.secrets.get(this.authKey); + if (!accessToken) return undefined; // We will never have a (valid) teamid without a token! + const teamId = (await this.secrets.get(this.teamIdKey)) ?? null; + return { accessToken, teamId }; } async setProject(token: string | undefined) { diff --git a/src/features/VercelManager.ts b/src/features/VercelManager.ts index 87d2de1..146dd79 100644 --- a/src/features/VercelManager.ts +++ b/src/features/VercelManager.ts @@ -20,11 +20,13 @@ export class VercelManager { private projectInfo: VercelResponse.info.project | null = null; private userInfo: VercelResponse.info.User | null = null; + private api: Api; public constructor(private readonly token: TokenManager) { - const refreshRate = workspace - .getConfiguration("vercel") - .get("RefreshRate") as number; + this.api = new Api(token); + const refreshRate = Number( + workspace.getConfiguration("vercel").get("RefreshRate") + ); setInterval( () => { this.onDidDeploymentsUpdated(); @@ -42,9 +44,12 @@ export class VercelManager { this.userInfo = null; }; } + async loggedIn() { + return !!(await this.token.getAuth())?.accessToken; + } async logIn(): Promise { - const apiToken = await getTokenOauth(); + const apiToken = await getTokenOauth(this.api); if (!apiToken) return false; await this.token.setAuth(apiToken); this.onDidDeploymentsUpdated(); @@ -69,18 +74,6 @@ export class VercelManager { get selectedProject() { return this.token.getProject(); } - /** Utility getter to return authentication token */ - get auth() { - return this.token.getAuth(); - } - /** Utility getter to return the proper fetch options for authentication */ - private async authHeader() { - const auth = await this.auth; - if (!auth) throw new Error("Not authenticated. Ensure user is logged in!"); - return { - headers: { Authorization: `Bearer ${auth}` }, - }; - } project = { getInfo: async (refresh: boolean = false) => { @@ -88,9 +81,9 @@ export class VercelManager { const selectedProject = this.selectedProject; if (!selectedProject) return void window.showErrorMessage("No project selected!"); - const result = await Api.projectInfo( + const result = await this.api.projectInfo( { projectId: selectedProject }, - await this.authHeader() + {} ); if (!result.ok) return; return (this.projectInfo = result); @@ -99,7 +92,7 @@ export class VercelManager { user = { getInfo: async (refresh: boolean = false) => { if (this.userInfo !== null && !refresh) return this.userInfo; - const response = await Api.userInfo({}, await this.authHeader()); + const response = await this.api.userInfo(undefined, undefined); if (!response.ok) return; return (this.userInfo = response.user); }, @@ -111,10 +104,10 @@ export class VercelManager { * or undefined if no project is selected or no user is authenticated */ getAll: async (): Promise => { - if (!(await this.auth) || !this.selectedProject) return []; - const response = await Api.environment.getAll( + if (!this.selectedProject) return []; + const response = await this.api.environment.getAll( { projectId: this.selectedProject }, - await this.authHeader() + undefined ); if (!response.ok) return (this.envList = []); const r = "envs" in response ? response.envs ?? [] : [response]; @@ -134,9 +127,9 @@ export class VercelManager { const projectId = this.selectedProject; if (!projectId) return void window.showErrorMessage("No project selected!"); - await Api.environment.create( + await this.api.environment.create( { projectId, body: { key, value, target, type: "encrypted" } }, - await this.authHeader() + undefined ); this.onDidEnvironmentsUpdated(); }, @@ -149,7 +142,7 @@ export class VercelManager { const projectId = this.selectedProject; if (!projectId) return void window.showErrorMessage("No project selected!"); - await Api.environment.remove({ projectId, id }, await this.authHeader()); + await this.api.environment.remove({ projectId, id }, undefined); this.onDidEnvironmentsUpdated(); }, /** @@ -162,18 +155,9 @@ export class VercelManager { const selectedProject = this.selectedProject; if (!selectedProject) return void window.showErrorMessage("No project selected!"); - await Api.environment.edit( - { - projectId: selectedProject, - id, - body: { - value, - target: targets, - }, - }, - { - headers: (await this.authHeader()).headers, - } + await this.api.environment.edit( + { projectId: selectedProject, id, body: { value, target: targets } }, + undefined ); this.onDidEnvironmentsUpdated(); }, @@ -191,18 +175,15 @@ export class VercelManager { * user or empty list if either doesn't exist */ getAll: async () => { - if (!this.selectedProject || !(await this.auth)) return []; + if (!this.selectedProject) return []; try { this.fetchingDeployments = true; const limit = workspace .getConfiguration("vercel") .get("DeploymentCount") as number; - const data = await Api.deployments( - { - projectId: this.selectedProject, - limit: limit ?? 20, - }, - await this.authHeader() + const data = await this.api.deployments( + { projectId: this.selectedProject, limit: limit ?? 20 }, + undefined ); if (!data.ok) return (this.deploymentsList = []); const r = data.deployments ?? []; diff --git a/src/utils/Api.ts b/src/utils/Api.ts index 2f5ef12..8b1ce6c 100644 --- a/src/utils/Api.ts +++ b/src/utils/Api.ts @@ -10,6 +10,8 @@ import type { VercelTargets, } from "../features/models"; import { objectKeys, typeGuard } from "tsafe"; +import type { TokenManager } from "../features/TokenManager"; +import type { OauthResult } from "./oauth"; /** The default TRet */ type TRetType = Record | unknown[]; @@ -17,7 +19,6 @@ type TRetType = Record | unknown[]; type TRequiredType = ParamMap | undefined; /** The default TRequiredFetch */ type TRequiredFetchType = RequestInit | undefined; -type AuthHeaders = { headers: { Authorization: string } }; type RequestHook = < Params extends ParamMap & TRequired, Req extends RequestInit & TRequiredFetch, @@ -28,22 +29,20 @@ type RequestHook = < }) => { params: ParamMap; req: RequestInit } | undefined | null | void; export class Api { + constructor(private token: TokenManager) {} /** Combines two objects, combining any object properties down one level * eg. {a: 1, b: { num: 2 }} and {c: 3, a: 4, b: { num2: 5 }} * {a: 4, b: {num: 2, num2: 5}, c: 3 } * Any non-object properties are overwritten if on a, or overwrite if on b. * Both arguments should be objects. if they are not objects, you will likely get an empty object back. */ - private static mergeHeaders(a: A, b?: undefined): A; - private static mergeHeaders( - a: A, - b: B - ): A & B; // This isn't *quite* correct, but it works for now. - private static mergeHeaders( + private mergeHeaders(a: A, b?: undefined): A; + private mergeHeaders(a: A, b: B): A & B; // This isn't *quite* correct, but it works for now. + private mergeHeaders( a: A, b?: B ): A & B; - private static mergeHeaders( + private mergeHeaders( a: A, b?: B ) { @@ -65,8 +64,13 @@ export class Api { return r; } - private static baseUrl = "https://api.vercel.com"; - private static base(path?: string, query?: ParamMap) { + /** Utility getter to return the proper fetch options for authentication */ + private async authHeader(auth: OauthResult) { + return { headers: { Authorization: `Bearer ${auth.accessToken}` } }; + } + + private baseUrl = "https://api.vercel.com"; + private base(path?: string, query?: ParamMap) { return urlcat(this.baseUrl, path ?? "", query ?? {}); } @@ -79,18 +83,20 @@ export class Api { * @typeparam TRequired is the required query parameters for the API call * @typeparam TRequiredFetch is the required fetch options for the API call */ - private static init< + private init< TRet extends TRetType = TRetType, TRequired extends TRequiredType = TRequiredType, TRequiredFetch extends TRequiredFetchType = TRequiredFetchType, >(initial: { path: string; params?: ParamMap; + authorization?: boolean; fetch?: RequestInit; hook?: RequestHook; }) { const initOpt = initial.params ?? {}; const initFetchOpt = initial.fetch ?? {}; + const useAuth = initial.authorization ?? true; const { path, hook } = initial; type Ret = (TRet & { ok: true }) | (VercelResponse.error & { ok: false }); //> Returns a function for fetching @@ -100,7 +106,15 @@ export class Api { ): Promise => { options ??= {} as typeof options; const mergedOptions = { ...initOpt, ...options }; - const mergedFetchOptions = this.mergeHeaders(initFetchOpt, fetchOptions); + let mergedFetchOptions = this.mergeHeaders(initFetchOpt, fetchOptions); + if (useAuth) { + const auth = await this.token.getAuth(); + if (!auth?.accessToken) + throw new Error("Not authenticated. Ensure user is logged in!"); + const authHeader = this.authHeader(auth); + mergedFetchOptions = this.mergeHeaders(mergedFetchOptions, authHeader); + mergedOptions.teamId = auth.teamId; + } // Final merged after hook. Note: these have a broader type to allow hook to return anything. let finalOptions: ParamMap = mergedOptions, @@ -149,41 +163,37 @@ export class Api { }; } - public static deployments = this.init< + public deployments = this.init< VercelResponse.deployment, - { projectId: string; limit?: number }, - AuthHeaders + { projectId: string; limit?: number; teamId?: string } >({ path: "/v6/deployments", fetch: { method: "GET", }, }); - public static projectInfo = this.init< + public projectInfo = this.init< VercelResponse.info.project, - { projectId: string }, - AuthHeaders + { projectId: string; teamId?: string } >({ path: "/v9/projects/:projectId", fetch: { method: "GET", }, }); - public static userInfo = this.init< + public userInfo = this.init< VercelResponse.info.user, - TRequiredType, - AuthHeaders + { teamId?: string } | undefined >({ path: "/v2/user", fetch: { method: "GET", }, }); - public static environment = { + public environment = { getAll: this.init< VercelResponse.environment.getAll, - { projectId: string }, - AuthHeaders + { projectId: string; teamId?: string } >({ path: "/v8/projects/:projectId/env", params: { decrypt: "true" }, @@ -193,8 +203,7 @@ export class Api { }), remove: this.init< VercelResponse.environment.remove, - { projectId: string; id: string }, - AuthHeaders + { projectId: string; id: string; teamId?: string } >({ path: "/v9/projects/:projectId/env/:id", fetch: { @@ -211,8 +220,8 @@ export class Api { target: VercelTargets[]; type: NonNullable; }; - }, - AuthHeaders + teamId?: string; + } >({ hook: o => ({ // turn the body param into a URLSearchParams object @@ -230,8 +239,8 @@ export class Api { projectId: string; id: string; body: { value: string; target: VercelTargets[] }; - }, - { headers: { Authorization: string } } + teamId?: string; + } >({ hook: o => ({ // turn the body param into a URLSearchParams object @@ -244,7 +253,7 @@ export class Api { }, }), }; - public static oauth = { + public oauth = { accessToken: this.init< VercelResponse.oauth.accessToken, { @@ -254,6 +263,7 @@ export class Api { client_id: string; client_secret: string; }; + teamId?: string; } >({ hook: o => ({ @@ -262,6 +272,7 @@ export class Api { params: { ...o.params, body: undefined }, }), path: "/v2/oauth/access_token", + authorization: false, // The user doesn't need to be authorized to use this endpoint! fetch: { method: "POST", headers: { diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index e8db533..54dbec9 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import * as vscode from "vscode"; import http from "http"; -import { Api } from "./Api"; +import type { Api } from "./Api"; import { listen } from "async-listen"; // These are constants configured in the vercel dashboard. They must match those values! const OAUTH_PORT = 9615; @@ -51,38 +52,45 @@ async function doOauth( server.close(); } } -async function getTokenFromCode(code: string): Promise { - const res = await Api.oauth.accessToken( +export type OauthResult = { accessToken: string; teamId: string | null }; +async function getTokenFromCode( + code: string, + api: Api +): Promise { + const res = await api.oauth.accessToken( { body: { code: code, - redirect_uri: OAUTH_URL, // eslint-disable-line @typescript-eslint/naming-convention - client_id: CLIENT_ID, // eslint-disable-line @typescript-eslint/naming-convention - client_secret: CLIENT_SEC, // eslint-disable-line @typescript-eslint/naming-convention + redirect_uri: OAUTH_URL, + client_id: CLIENT_ID, + client_secret: CLIENT_SEC, }, }, undefined ); if (!res.ok) return; - return res.access_token; + const { access_token, team_id } = res; // eslint-disable-line @typescript-eslint/naming-convention + return { accessToken: access_token, teamId: team_id }; } -export async function getTokenOauth() { +export async function getTokenOauth( + api: Api +): Promise { // Check well known ip before starting a server and a browser const req = await fetch("https://1.1.1.1").catch(() => null); if (!req?.ok) { const msg = `Failed to authenticate with Vercel (Network error!). ${req?.statusText}`; - return await vscode.window.showErrorMessage(msg); + return void vscode.window.showErrorMessage(msg); } const resUrl = await doOauth(OAUTH_PORT, OAUTH_PATH); const code = resUrl.searchParams.get("code"); if (!code) { const msg = "Failed to authenticate with Vercel (Couldn't get code)."; - return await vscode.window.showErrorMessage(msg); + return void vscode.window.showErrorMessage(msg); } - const accessToken = await getTokenFromCode(code); - if (!accessToken) { + const accessToken = await getTokenFromCode(code, api); + if (!accessToken?.accessToken) { const msg = `Failed to authenticate with Vercel. (Couldn't get access token)`; - return await vscode.window.showErrorMessage(msg); + return void vscode.window.showErrorMessage(msg); } return accessToken; }