mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Fix GH tokens refresh & devtool tabs
This commit is contained in:
parent
bb109a5cbc
commit
d30962bf66
@ -2,6 +2,9 @@
|
||||
|
||||
This file contains knowledge learned while working on the codebase in Q&A format.
|
||||
|
||||
## Q: How are connected-account OAuth tokens stored and refreshed?
|
||||
A: Connected accounts live in `ProjectUserOAuthAccount`. Stored refresh tokens are in `OAuthToken` (`oauthAccountId`, `scopes`, `isValid`), and cached access tokens are in `OAuthAccessToken` (`expiresAt`, `scopes`, `isValid`). `retrieveOrRefreshAccessToken` first uses an unexpired valid access token with matching scopes; otherwise it looks for valid refresh tokens with matching scopes and invalidates only those that the provider explicitly rejects.
|
||||
|
||||
## Q: Which Hexclave rename compatibility layers should be avoided in PR #1475 follow-ups?
|
||||
A: Do not keep backwards compatibility for the MCP tool name, cross-domain auth query parameter names, `NEXT_PUBLIC_STACK_PORT_PREFIX`, or a parallel `hexclaveAppInternalsSymbol`. For refresh/access cookies, read both legacy Stack and new Hexclave cookie names, but only write the canonical Hexclave cookies.
|
||||
|
||||
|
||||
@ -61,6 +61,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
|
||||
return await retrieveOrRefreshAccessToken({
|
||||
prisma,
|
||||
providerInstance,
|
||||
providerId: params.provider_id,
|
||||
tenancyId: auth.tenancy.id,
|
||||
oauthAccountIds: [oauthAccount.id],
|
||||
scope: data.scope,
|
||||
|
||||
@ -56,6 +56,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
|
||||
return await retrieveOrRefreshAccessToken({
|
||||
prisma,
|
||||
providerInstance,
|
||||
providerId: params.provider_id,
|
||||
tenancyId: auth.tenancy.id,
|
||||
oauthAccountIds: oauthAccounts.map(a => a.id),
|
||||
scope: data.scope,
|
||||
|
||||
@ -94,15 +94,20 @@ import.meta.vitest?.describe("isSharedAccessTokenBlocked", () => {
|
||||
export async function retrieveOrRefreshAccessToken(options: {
|
||||
prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>,
|
||||
providerInstance: OAuthBaseProvider,
|
||||
providerId: string,
|
||||
tenancyId: string,
|
||||
oauthAccountIds: string[],
|
||||
scope: string | undefined,
|
||||
errorContext: Record<string, unknown>,
|
||||
}): Promise<{ access_token: string }> {
|
||||
const { prisma, providerInstance, tenancyId, oauthAccountIds, scope, errorContext } = options;
|
||||
const { prisma, providerInstance, providerId, tenancyId, oauthAccountIds, scope, errorContext } = options;
|
||||
const accountIdFilter = oauthAccountIds.length === 1
|
||||
? { oauthAccountId: oauthAccountIds[0] }
|
||||
: { oauthAccountId: { in: oauthAccountIds } };
|
||||
const reauthorizeDetails = "The stored OAuth refresh token is missing, expired, revoked, or no longer accepted by the OAuth provider. The user needs to re-authorize this connected account.";
|
||||
const requiredScopeDetails = scope
|
||||
? `The OAuth connection does not have a usable refresh token with the required scope (${scope}). The user needs to re-authorize this connected account with the requested scope.`
|
||||
: "The OAuth connection does not have a usable refresh token for this connected account. The user needs to re-authorize this connected account.";
|
||||
|
||||
// ====================== retrieve access token if it exists ======================
|
||||
const accessTokens = await prisma.oAuthAccessToken.findMany({
|
||||
@ -142,10 +147,15 @@ export async function retrieveOrRefreshAccessToken(options: {
|
||||
return extractScopes(scope || "").every((s) => t.scopes.includes(s));
|
||||
});
|
||||
|
||||
if (filteredRefreshTokens.length === 0) {
|
||||
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
|
||||
if (refreshTokens.length === 0) {
|
||||
throw new KnownErrors.OAuthAccessTokenNotAvailable(providerId, reauthorizeDetails);
|
||||
}
|
||||
|
||||
if (filteredRefreshTokens.length === 0) {
|
||||
throw new KnownErrors.OAuthAccessTokenNotAvailable(providerId, requiredScopeDetails);
|
||||
}
|
||||
|
||||
let invalidatedRefreshTokenDuringAttempt = false;
|
||||
for (const token of filteredRefreshTokens) {
|
||||
const tokenSetResult = await providerInstance.getAccessToken({
|
||||
refreshToken: token.refreshToken,
|
||||
@ -162,6 +172,7 @@ export async function retrieveOrRefreshAccessToken(options: {
|
||||
where: { id: token.id },
|
||||
data: { isValid: false },
|
||||
});
|
||||
invalidatedRefreshTokenDuringAttempt = true;
|
||||
continue;
|
||||
}
|
||||
case "temporarily-unavailable": {
|
||||
@ -247,5 +258,8 @@ export async function retrieveOrRefreshAccessToken(options: {
|
||||
}
|
||||
}
|
||||
|
||||
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
|
||||
throw new KnownErrors.OAuthAccessTokenNotAvailable(
|
||||
providerId,
|
||||
invalidatedRefreshTokenDuringAttempt ? reauthorizeDetails : requiredScopeDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@ -217,7 +217,13 @@ export function getOAuthAccessTokenRefreshError(error: unknown, options: {
|
||||
return { type: "unexpected", cause: error, ...metadata };
|
||||
}
|
||||
|
||||
function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: number): TokenSet {
|
||||
type DefaultAccessTokenExpiresInMillis = number | ((tokenSet: OIDCTokenSet) => number | undefined);
|
||||
|
||||
function getDefaultAccessTokenExpiresInMillis(tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis): number | undefined {
|
||||
return typeof defaultAccessTokenExpiresInMillis === "function" ? defaultAccessTokenExpiresInMillis(tokenSet) : defaultAccessTokenExpiresInMillis;
|
||||
}
|
||||
|
||||
function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis): TokenSet {
|
||||
if (!tokenSet.access_token) {
|
||||
throw new HexclaveAssertionError(`No access token received from ${providerName}.`, { tokenSet, providerName });
|
||||
}
|
||||
@ -225,8 +231,9 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
|
||||
// if expires_in or expires_at provided, use that
|
||||
// otherwise, if defaultAccessTokenExpiresInMillis provided, use that
|
||||
// otherwise, use 1h, and log an error
|
||||
const defaultExpiresInMillis = getDefaultAccessTokenExpiresInMillis(tokenSet, defaultAccessTokenExpiresInMillis);
|
||||
|
||||
if (!tokenSet.expires_in && !tokenSet.expires_at && !defaultAccessTokenExpiresInMillis) {
|
||||
if (!tokenSet.expires_in && !tokenSet.expires_at && !defaultExpiresInMillis) {
|
||||
captureError("processTokenSet", new HexclaveAssertionError(`No expires_in or expires_at received from OAuth provider ${providerName}. Falling back to 1h`, { tokenSetKeys: Object.keys(tokenSet) }));
|
||||
}
|
||||
|
||||
@ -237,8 +244,8 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
|
||||
accessTokenExpiredAt: tokenSet.expires_in ?
|
||||
new Date(Date.now() + tokenSet.expires_in * 1000) :
|
||||
tokenSet.expires_at ? new Date(tokenSet.expires_at * 1000) :
|
||||
defaultAccessTokenExpiresInMillis ?
|
||||
new Date(Date.now() + defaultAccessTokenExpiresInMillis) :
|
||||
defaultExpiresInMillis ?
|
||||
new Date(Date.now() + defaultExpiresInMillis) :
|
||||
new Date(Date.now() + 3600 * 1000),
|
||||
};
|
||||
}
|
||||
@ -249,7 +256,7 @@ export abstract class OAuthBaseProvider {
|
||||
public readonly scope: string,
|
||||
public readonly redirectUri: string,
|
||||
public readonly authorizationExtraParams?: Record<string, string>,
|
||||
public readonly defaultAccessTokenExpiresInMillis?: number,
|
||||
public readonly defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis,
|
||||
public readonly noPKCE?: boolean,
|
||||
public readonly openid?: boolean,
|
||||
public readonly alternativeIssuers?: string[],
|
||||
@ -262,7 +269,7 @@ export abstract class OAuthBaseProvider {
|
||||
redirectUri: string,
|
||||
baseScope: string,
|
||||
authorizationExtraParams?: Record<string, string>,
|
||||
defaultAccessTokenExpiresInMillis?: number,
|
||||
defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis,
|
||||
tokenEndpointAuthMethod?: "client_secret_post" | "client_secret_basic",
|
||||
noPKCE?: boolean,
|
||||
alternativeIssuers?: string[],
|
||||
|
||||
@ -23,10 +23,15 @@ export class GithubProvider extends OAuthBaseProvider {
|
||||
userinfoEndpoint: "https://api.github.com/user",
|
||||
redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/github",
|
||||
baseScope: "user:email",
|
||||
// GitHub token does not expire except for lack of use in a year
|
||||
// We set a default of 1 year
|
||||
// GitHub can return either non-expiring OAuth-App-style access tokens, or
|
||||
// expiring user tokens with refresh tokens. If GitHub gives us expires_in,
|
||||
// the base provider uses that real value. This fallback is only for older
|
||||
// responses without explicit expiry: refresh-token responses should be
|
||||
// treated as short-lived, while access-token-only responses are long-lived
|
||||
// and are still checked against /user before being returned.
|
||||
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens
|
||||
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#user-token-expired-due-to-github-app-configuration
|
||||
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 8, // 8 hours
|
||||
defaultAccessTokenExpiresInMillis: (tokenSet) => tokenSet.refresh_token ? 1000 * 60 * 60 * 8 : 1000 * 60 * 60 * 24 * 365,
|
||||
...options,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -76,6 +76,7 @@ function PageClientInner() {
|
||||
const [projectOnboardingStates, setProjectOnboardingStates] = useState<Map<string, ProjectOnboardingState | null>>(new Map());
|
||||
const [loadingStatuses, setLoadingStatuses] = useState(true);
|
||||
const [projectName, setProjectName] = useState(displayNameFromSearch ?? "");
|
||||
const hasProjectName = projectName.trim().length > 0;
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [creatingTeam, setCreatingTeam] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
@ -376,6 +377,7 @@ function PageClientInner() {
|
||||
</DesignButton>
|
||||
<DesignButton
|
||||
className="rounded-xl"
|
||||
disabled={!hasProjectName || creatingProject}
|
||||
loading={creatingProject}
|
||||
onClick={() => {
|
||||
if (!beginPendingAction(creatingProjectRef, setCreatingProject)) {
|
||||
|
||||
@ -3,6 +3,12 @@ import { localhostUrl } from "../../../../helpers/ports";
|
||||
import { Auth, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers";
|
||||
|
||||
const mockOAuthUrl = (path: string) => localhostUrl("14", path);
|
||||
const reauthorizeAccessTokenDetails = "The stored OAuth refresh token is missing, expired, revoked, or no longer accepted by the OAuth provider. The user needs to re-authorize this connected account.";
|
||||
const spotifyAccessTokenNotAvailableError = `Failed to retrieve an OAuth access token for the connected account (provider: spotify). ${reauthorizeAccessTokenDetails}`;
|
||||
|
||||
function missingScopeDetails(scope: string) {
|
||||
return `The OAuth connection does not have a usable refresh token with the required scope (${scope}). The user needs to re-authorize this connected account with the requested scope.`;
|
||||
}
|
||||
|
||||
it("should use the connected account access token to access the userinfo endpoint of the oauth provider", async ({ expect }) => {
|
||||
await Auth.OAuth.signIn();
|
||||
@ -122,6 +128,28 @@ it("should refresh the connected account access token when it is revoked from th
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return a scope-specific error when connected account tokens do not have the requested scope", async ({ expect }) => {
|
||||
await Auth.OAuth.signIn();
|
||||
|
||||
const scope = "missing-scope";
|
||||
const details = missingScopeDetails(scope);
|
||||
const response = await niceBackendFetch("/api/v1/connected-accounts/me/spotify/access-token", {
|
||||
accessType: "client",
|
||||
method: "POST",
|
||||
body: { scope },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details,
|
||||
},
|
||||
error: `Failed to retrieve an OAuth access token for the connected account (provider: spotify). ${details}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should prompt the user to re-authorize the connected account when the refresh token is revoked from the oauth provider", async ({ expect }) => {
|
||||
await Auth.OAuth.signIn();
|
||||
|
||||
@ -175,19 +203,15 @@ it("should prompt the user to re-authorize the connected account when the refres
|
||||
scope: "openid",
|
||||
},
|
||||
});
|
||||
expect(response5).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
"error": "The OAuth connection does not have the required scope.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(response5.status).toBe(400);
|
||||
expect(response5.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle access_denied error gracefully when refreshing token", async ({ expect }) => {
|
||||
@ -228,7 +252,7 @@ it("should handle access_denied error gracefully when refreshing token", async (
|
||||
}),
|
||||
});
|
||||
|
||||
// Try to get a new access token - should fail gracefully with OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE
|
||||
// Try to get a new access token - should fail gracefully and ask the user to re-authorize
|
||||
const response3 = await niceBackendFetch("/api/v1/connected-accounts/me/spotify/access-token", {
|
||||
accessType: "client",
|
||||
method: "POST",
|
||||
@ -236,19 +260,15 @@ it("should handle access_denied error gracefully when refreshing token", async (
|
||||
scope: "openid",
|
||||
},
|
||||
});
|
||||
expect(response3).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
"error": "The OAuth connection does not have the required scope.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle consent_required error gracefully when refreshing token", async ({ expect }) => {
|
||||
@ -297,19 +317,15 @@ it("should handle consent_required error gracefully when refreshing token", asyn
|
||||
scope: "openid",
|
||||
},
|
||||
});
|
||||
expect(response3).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
"error": "The OAuth connection does not have the required scope.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid_token error gracefully when refreshing token", async ({ expect }) => {
|
||||
@ -358,19 +374,15 @@ it("should handle invalid_token error gracefully when refreshing token", async (
|
||||
scope: "openid",
|
||||
},
|
||||
});
|
||||
expect(response3).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
"error": "The OAuth connection does not have the required scope.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle unauthorized_client error gracefully when refreshing token", async ({ expect }) => {
|
||||
@ -419,19 +431,15 @@ it("should handle unauthorized_client error gracefully when refreshing token", a
|
||||
scope: "openid",
|
||||
},
|
||||
});
|
||||
expect(response3).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
"error": "The OAuth connection does not have the required scope.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list all connected accounts for the current user", async ({ expect }) => {
|
||||
@ -810,9 +818,16 @@ it("should get access token for specific account when user has multiple accounts
|
||||
body: { scope: "openid" },
|
||||
});
|
||||
// The mock OAuth server doesn't have a refresh token for this manually created account,
|
||||
// so it will return an error about not having the required scope
|
||||
// so it will return a re-authorization error instead of a scope-only error.
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE");
|
||||
expect(response.body).toEqual({
|
||||
code: "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
|
||||
details: {
|
||||
provider: "spotify",
|
||||
details: reauthorizeAccessTokenDetails,
|
||||
},
|
||||
error: spotifyAccessTokenNotAvailableError,
|
||||
});
|
||||
});
|
||||
|
||||
it("should differentiate between accounts with same provider but different account IDs", async ({ expect }) => {
|
||||
|
||||
@ -219,6 +219,7 @@ export function isLocalhost(urlOrString: string | URL) {
|
||||
if (!url) return false;
|
||||
if (url.hostname === "localhost" || url.hostname.endsWith(".localhost")) return true;
|
||||
if (url.hostname.match(/^127\.\d+\.\d+\.\d+$/)) return true;
|
||||
if (url.hostname === "[::1]" || url.hostname === "::1") return true;
|
||||
return false;
|
||||
}
|
||||
import.meta.vitest?.test("isLocalhost", ({ expect }) => {
|
||||
@ -228,6 +229,7 @@ import.meta.vitest?.test("isLocalhost", ({ expect }) => {
|
||||
expect(isLocalhost("http://sub.localhost")).toBe(true);
|
||||
expect(isLocalhost("http://127.0.0.1")).toBe(true);
|
||||
expect(isLocalhost("http://127.1.2.3")).toBe(true);
|
||||
expect(isLocalhost("http://[::1]")).toBe(true);
|
||||
|
||||
// Test with non-localhost URLs
|
||||
expect(isLocalhost("https://example.com")).toBe(false);
|
||||
|
||||
@ -4,6 +4,7 @@ import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/cl
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls";
|
||||
import type { StackClientApp } from "../lib/stack-app";
|
||||
import { envVars } from "../lib/env";
|
||||
import { getBaseUrl } from "../lib/stack-app/apps/implementations/common";
|
||||
import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/stack-app/common";
|
||||
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
|
||||
@ -203,6 +204,17 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
|
||||
return getBaseUrl(opts.baseUrl);
|
||||
}
|
||||
|
||||
function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
|
||||
return envVars.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
|
||||
}
|
||||
|
||||
function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
|
||||
if (shouldShowDashboardTab(app)) {
|
||||
return TABS;
|
||||
}
|
||||
return TABS.filter((tab) => tab.id !== 'dashboard');
|
||||
}
|
||||
|
||||
function deriveDashboardBaseUrl(apiBaseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(apiBaseUrl);
|
||||
@ -2170,7 +2182,11 @@ function createPanel(
|
||||
panel.style.height = state.get().panelHeight + 'px';
|
||||
}
|
||||
|
||||
applyPanelMode(state.get().activeTab);
|
||||
const tabs = getTabsForApp(app);
|
||||
const storedActiveTab = state.get().activeTab;
|
||||
const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab;
|
||||
|
||||
applyPanelMode(activeTab);
|
||||
|
||||
const inner = h('div', { className: 'sdt-panel-inner' });
|
||||
|
||||
@ -2186,7 +2202,7 @@ function createPanel(
|
||||
|
||||
const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn);
|
||||
|
||||
const tabBar = createTabBar(TABS, state.get().activeTab, (id) => {
|
||||
const tabBar = createTabBar(tabs, activeTab, (id) => {
|
||||
state.update({ activeTab: id as TabId });
|
||||
applyPanelMode(id as TabId, { animate: true });
|
||||
showTab(id as TabId);
|
||||
@ -2260,7 +2276,7 @@ function createPanel(
|
||||
pane.classList.add('sdt-tab-pane-active');
|
||||
}
|
||||
|
||||
showTab(state.get().activeTab);
|
||||
showTab(activeTab);
|
||||
|
||||
function addResizeHandle(edge: 'top' | 'left' | 'top-left') {
|
||||
const handle = h('div', { className: `sdt-resize-handle sdt-resize-${edge}` });
|
||||
@ -2361,7 +2377,6 @@ export function createDevTool(app: StackClientApp<true>): () => void {
|
||||
|
||||
function openPanel() {
|
||||
if (panel) return;
|
||||
state.update({ activeTab: 'overview' });
|
||||
panel = createPanel(app, state, logStore, closePanelAndPersistClosed);
|
||||
wrapper.appendChild(panel.element);
|
||||
}
|
||||
|
||||
@ -282,7 +282,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const result = await this._interface.createProviderAccessToken(providerId, scope || "", session);
|
||||
return { accessToken: result.access_token };
|
||||
} catch (err) {
|
||||
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@ -317,7 +317,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const result = await this._interface.createProviderAccessTokenByAccount(providerId, providerAccountId, scope, session);
|
||||
return { accessToken: result.access_token };
|
||||
} catch (err) {
|
||||
if (KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err)) {
|
||||
if (KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@ -123,7 +123,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const result = await this._interface.createServerProviderAccessToken(userId, providerId, scope || "");
|
||||
return { accessToken: result.access_token };
|
||||
} catch (err) {
|
||||
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@ -158,7 +158,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const result = await this._interface.createServerProviderAccessTokenByAccount(userId, providerId, providerAccountId, scope || "");
|
||||
return { accessToken: result.access_token };
|
||||
} catch (err) {
|
||||
if (!(KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user