From 6697a6660591d8f354af9251c4ab17395ada13e3 Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:39:13 +0200 Subject: [PATCH 1/3] feat: Add support for redmine oauth2 login flow (WIP) --- src/api/redmine/RedmineApiClient.ts | 112 +++++++++- src/api/redmine/RedmineAuthenticationError.ts | 6 + src/api/redmine/types.ts | 9 + src/components/error/ErrorComponent.tsx | 5 +- src/lang/en.json | 13 ++ src/provider/QueryClientProvider.tsx | 2 + src/provider/RedmineApiProvider.tsx | 2 +- src/provider/SettingsProvider.tsx | 54 ++++- src/routes/settings.tsx | 191 ++++++++++++++---- wxt.config.ts | 4 +- 10 files changed, 343 insertions(+), 55 deletions(-) create mode 100644 src/api/redmine/RedmineAuthenticationError.ts diff --git a/src/api/redmine/RedmineApiClient.ts b/src/api/redmine/RedmineApiClient.ts index 2e7bcd19..ef276e9e 100644 --- a/src/api/redmine/RedmineApiClient.ts +++ b/src/api/redmine/RedmineApiClient.ts @@ -1,6 +1,9 @@ -import axios, { AxiosInstance } from "axios"; +import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; +import { Settings } from "@/provider/SettingsProvider"; +import axios, { AxiosInstance, isAxiosError } from "axios"; import { formatISO } from "date-fns"; import qs from "qs"; +import { browser } from "wxt/browser"; import { MissingRedmineConfigError } from "./MissingRedmineConfigError"; import { TCreateIssue, @@ -10,6 +13,7 @@ import { TIssueStatus, TIssueTracker, TMembership, + TOAuthTokenResponse, TPaginatedResponse, TProject, TReference, @@ -26,12 +30,15 @@ import { export class RedmineApiClient { private instance: AxiosInstance; public id = crypto.randomUUID(); + private auth?: Settings["auth"]; - constructor(redmineURL: string, redmineApiKey: string) { + constructor(redmineURL: string, auth?: Settings["auth"]) { + this.auth = auth; this.instance = axios.create({ baseURL: redmineURL, headers: { - "X-Redmine-API-Key": redmineApiKey, + ...(auth?.method === "apiKey" && { "X-Redmine-API-Key": auth.apiKey }), + ...(auth?.method === "oauth2" && { Authorization: `Bearer ${auth.oauth2?.accessToken}` }), "Cache-Control": "no-cache, no-store, max-age=0", Expires: "0", }, @@ -40,8 +47,12 @@ export class RedmineApiClient { if (!config.baseURL) { throw new MissingRedmineConfigError(); } + if (auth?.method === "oauth2" && !auth.oauth2?.accessToken && config.url !== "/oauth/token") { + throw new RedmineAuthenticationError("Authorization required"); + } return config; }); + this.instance.interceptors.response.use( (response) => { const contentType = response.headers["content-type"]; @@ -51,11 +62,15 @@ export class RedmineApiClient { return response; }, (error) => { - if (error.response?.status === 401) { - throw new Error("Unauthorized"); - } - if (error.response?.status === 403) { - throw new Error("Forbidden"); + if (isAxiosError(error)) { + if (error.response?.status === 401) { + const message = error.response.headers["www-authenticate"].match(/error_description="([^"]+)"/)?.[1]; + throw new RedmineAuthenticationError(message); + } + + if (error.response?.status === 403) { + throw new Error("Forbidden"); + } } return Promise.reject(error); } @@ -295,4 +310,85 @@ export class RedmineApiClient { async getCurrentUser(): Promise { return this.instance.get("/users/current.json?include=memberships").then((res) => res.data.user); } + + // Auth + getAuthorizeUrl({ clientId, redirectUri, scope }: { clientId: string; redirectUri: string; scope: string }): string { + return `${this.instance.defaults.baseURL}/oauth/authorize?${qs.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope, + })}`; + } + + async getAccessToken({ code, redirectUri, clientId, clientSecret }: { code: string; redirectUri: string; clientId: string; clientSecret: string }) { + return this.instance + .post("/oauth/token", { + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }) + .then((res) => res.data); + } + + async refreshAccessToken({ refreshToken, clientId, clientSecret }: { refreshToken: string; clientId: string; clientSecret: string }) { + return this.instance + .post("/oauth/token", { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }) + .then((res) => res.data); + } + + async startOAuth2Authorization() { + if (!this.auth?.oauth2?.clientId || !this.auth?.oauth2?.clientSecret) { + throw new Error("OAuth2 Client ID and Client Secret are required for OAuth2 authentication"); + } + + const redirectUri = browser.identity.getRedirectURL(); + const authorizeUrl = this.getAuthorizeUrl({ + clientId: this.auth.oauth2.clientId, + redirectUri, + scope: "view_project search_project view_members view_issues view_time_entries", + }); + + // Authorize and get the code + const redirectURLString = await browser.identity.launchWebAuthFlow({ + interactive: true, + url: authorizeUrl, + }); + if (!redirectURLString) { + throw new Error("No redirect URL received"); + } + const redirectURL = new URL(redirectURLString); + if (redirectURL.searchParams.get("error")) { + if (redirectURL.searchParams.get("error") === "access_denied") { + throw new Error("Authorization was denied. Please allow access to connect your Redmine account."); + } + const errorDescription = redirectURL.searchParams.get("error_description") || "Unknown error"; + throw new Error(`Authorization error: ${errorDescription}`); + } + const code = redirectURL.searchParams.get("code"); + if (!code) { + throw new Error("Authorization code not found"); + } + + // Exchange the code for tokens + const tokenResponse = await this.getAccessToken({ + code, + redirectUri, + clientId: this.auth.oauth2.clientId, + clientSecret: this.auth.oauth2.clientSecret, + }); + + return { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt: (tokenResponse.created_at + tokenResponse.expires_in) * 1000, + }; + } } diff --git a/src/api/redmine/RedmineAuthenticationError.ts b/src/api/redmine/RedmineAuthenticationError.ts new file mode 100644 index 00000000..ef3c09b0 --- /dev/null +++ b/src/api/redmine/RedmineAuthenticationError.ts @@ -0,0 +1,6 @@ +export class RedmineAuthenticationError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = RedmineAuthenticationError.name; + } +} diff --git a/src/api/redmine/types.ts b/src/api/redmine/types.ts index 231c5522..1df092d5 100644 --- a/src/api/redmine/types.ts +++ b/src/api/redmine/types.ts @@ -297,3 +297,12 @@ export type TPaginatedResponse = { offset: number; limit: number; } & T; + +export type TOAuthTokenResponse = { + token_type: string; + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; + created_at: number; +}; diff --git a/src/components/error/ErrorComponent.tsx b/src/components/error/ErrorComponent.tsx index 8d2e19e5..7020dd1b 100644 --- a/src/components/error/ErrorComponent.tsx +++ b/src/components/error/ErrorComponent.tsx @@ -1,4 +1,5 @@ import { MissingRedmineConfigError } from "@/api/redmine/MissingRedmineConfigError"; +import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; import { getErrorMessage } from "@/utils/error"; import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorComponentProps } from "@tanstack/react-router"; @@ -20,7 +21,9 @@ export function ErrorComponent({ error, reset: resetPage }: ErrorComponentProps) ? formatMessage({ id: "general.error.api-error" }) : error instanceof MissingRedmineConfigError ? formatMessage({ id: "general.error.missing-redmine-configuration" }) - : formatMessage({ id: "general.error.unknown-error" }, { name: error.name })} + : error instanceof RedmineAuthenticationError + ? formatMessage({ id: "general.error.redmine-authentication-error" }) + : formatMessage({ id: "general.error.unknown-error" }, { name: error.name })} {!(error instanceof MissingRedmineConfigError) && ( diff --git a/src/lang/en.json b/src/lang/en.json index e57aa313..adb66a7d 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -138,9 +138,21 @@ "settings.redmine.url": "Redmine URL", "settings.redmine.url.validation.required": "URL is required", "settings.redmine.url.validation.valid-url": "Enter a valid URL", + "settings.redmine.auth-method": "Authentication method", + "settings.redmine.auth-method.api-key": "API Key", + "settings.redmine.auth-method.oauth2": "OAuth2", "settings.redmine.api-key": "Redmine API-Key", "settings.redmine.api-key.validation.required": "API-Key is required", "settings.redmine.api-key.hint": "Where can I find my API-Key? here", + "settings.redmine.oauth2.client-id": "Client ID", + "settings.redmine.oauth2.client-secret": "Client Secret", + "settings.redmine.oauth2.setup": "OAuth2 setup", + "settings.redmine.oauth2.setup.description": "To use OAuth2, you need to register an OAuth2 application in Redmine. This requires administrator permissions.", + "settings.redmine.oauth2.setup.application-name": "Name", + "settings.redmine.oauth2.setup.redirect-uri": "Redirect URI", + "settings.redmine.oauth2.token-expires": "Token expires on {date} at {time}", + "settings.redmine.oauth2.authorize": "Authorize with OAuth2", + "settings.redmine.oauth2.authorization-failed": "OAuth2 authorization failed: {error}", "settings.redmine.connecting": "Connecting...", "settings.redmine.connection-failed": "Connection failed", "settings.redmine.connection-successful": "Connection successful!", @@ -192,6 +204,7 @@ "general.retry": "Retry", "general.error.api-error": "API-Error", "general.error.missing-redmine-configuration": "Redmine URL is not configured", + "general.error.redmine-authentication-error": "Redmine authentication error", "general.error.unknown-error": "Unknown error: {name}", "general.error.page-not-found": "Page not found", diff --git a/src/provider/QueryClientProvider.tsx b/src/provider/QueryClientProvider.tsx index 7d4766c2..f6a09ec8 100644 --- a/src/provider/QueryClientProvider.tsx +++ b/src/provider/QueryClientProvider.tsx @@ -1,4 +1,5 @@ import { MissingRedmineConfigError } from "@/api/redmine/MissingRedmineConfigError"; +import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; import { getErrorMessage } from "@/utils/error"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { MutationCache, QueryCache, QueryClient, useIsRestoring } from "@tanstack/react-query"; @@ -45,6 +46,7 @@ export const queryClient = new QueryClient({ // Skip if Redmine URL is not configured if (error instanceof MissingRedmineConfigError) return; + if (error instanceof RedmineAuthenticationError) return; if (!isAxiosError(error)) { toast.error( diff --git a/src/provider/RedmineApiProvider.tsx b/src/provider/RedmineApiProvider.tsx index c111a47c..0f2c5109 100644 --- a/src/provider/RedmineApiProvider.tsx +++ b/src/provider/RedmineApiProvider.tsx @@ -7,7 +7,7 @@ const RedmineApiContext = createContext(null); const RedmineApiProvider = ({ children }: { children: ReactNode }) => { const { settings } = useSettings(); - return {children}; + return {children}; }; export const useRedmineApi = () => use(RedmineApiContext)!; diff --git a/src/provider/SettingsProvider.tsx b/src/provider/SettingsProvider.tsx index 4547a3fd..f1879324 100644 --- a/src/provider/SettingsProvider.tsx +++ b/src/provider/SettingsProvider.tsx @@ -13,19 +13,47 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType>; const defaultSettings: Settings = { language: "browser", redmineURL: "", - redmineApiKey: "", + auth: { + method: "apiKey", + apiKey: "", + oauth2: { + clientId: "", + clientSecret: "", + }, + }, features: { autoPauseOnSwitch: true, roundToInterval: false, @@ -109,6 +144,13 @@ export const runSettingsMigration = async () => { settings.style.showIssuesPriority = undefined; } + if (settings.redmineApiKey) { + settings.auth = { + method: "apiKey", + apiKey: settings.redmineApiKey, + }; + } + if (JSON.stringify(settings) !== JSON.stringify(settingsData)) { await setStorage("settings", settings); } diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 8aa12bf5..494ce1a0 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -1,10 +1,12 @@ /* eslint-disable react/no-children-prop */ import { useTestRedmineConnection } from "@/api/redmine/hooks/useTestRedmineConnection"; import { RedmineApiClient } from "@/api/redmine/RedmineApiClient"; +import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; import { Portal } from "@/components/general/Portal"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { FieldDescription, FieldGroup } from "@/components/ui/field"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"; import { useStore as useFormStore } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; @@ -16,6 +18,7 @@ import { ArrowUpIcon, BugIcon, ChevronRightIcon, + CopyIcon, ExternalLinkIcon, GlobeIcon, Loader2Icon, @@ -293,7 +296,7 @@ const RedmineServerSection = withForm({ onChange: settingsSchema(), }, render: function Render({ form }) { - const { formatMessage } = useIntl(); + const { formatMessage, formatDate, formatTime } = useIntl(); const [editRedmineInstance, setEditRedmineInstance] = useState(!form.state.values.redmineURL); const [redmineApiClient, setRedmineApiClient] = useState(undefined); @@ -317,9 +320,13 @@ const RedmineServerSection = withForm({ {editRedmineInstance ? ( ({ - isValid: !!state.values.redmineURL && !!state.values.redmineApiKey && !state.errorMap.onChange?.redmineURL, - })} + selector={(state) => { + const { redmineURL, auth } = state.values; + const urlOk = !!redmineURL && !state.errorMap.onChange?.redmineURL; + return { + isValid: urlOk && ((auth.method === "apiKey" && !!auth.apiKey) || (auth.method === "oauth2" && !!auth.oauth2?.clientId && !!auth.oauth2?.clientSecret)), + }; + }} children={({ isValid }) => ( + )} + + )} ) : redmineConnection.data ? ( <> @@ -413,9 +521,7 @@ const RedmineServerSection = withForm({ {formatMessage( - { - id: "settings.redmine.hello-user", - }, + { id: "settings.redmine.hello-user" }, { firstName: redmineConnection.data.firstname, lastName: redmineConnection.data.lastname, @@ -424,6 +530,17 @@ const RedmineServerSection = withForm({ } )} + {form.state.values.auth.method === "oauth2" && form.state.values.auth.oauth2?.expiresAt && ( + + {formatMessage( + { id: "settings.redmine.oauth2.token-expires" }, + { + date: formatDate(form.state.values.auth.oauth2.expiresAt, { dateStyle: "medium" }), + time: formatTime(form.state.values.auth.oauth2.expiresAt, { timeStyle: "short" }), + } + )} + + )} ) : undefined} diff --git a/wxt.config.ts b/wxt.config.ts index bce9e379..b84e878f 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -32,8 +32,6 @@ export default defineConfig({ "128": "/icon/128.png", }, homepage_url: "https://github.com/CrawlerCode/redmine-time-tracking", - permissions: ["storage", "tabs", "activeTab", "scripting"], - host_permissions: ["http://*/*", "https://*/*"], ...(browser === "chrome" && { key: mode === "release" @@ -52,6 +50,8 @@ export default defineConfig({ }, }, }), + permissions: ["storage", "tabs", "activeTab", "scripting", "identity"], + host_permissions: ["http://*/*", "https://*/*"], }), hooks: { "build:manifestGenerated": (wxt, manifest) => { From bb928f6b9e4bf631b9006307bce19d3f64cf3ed1 Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:27:45 +0200 Subject: [PATCH 2/3] feat: Implement refresh token --- src/api/redmine/RedmineApiClient.ts | 103 ++++++++++++++++++++-------- src/lang/en.json | 1 - src/provider/SettingsProvider.tsx | 3 - src/routes/settings.tsx | 24 ++----- 4 files changed, 79 insertions(+), 52 deletions(-) diff --git a/src/api/redmine/RedmineApiClient.ts b/src/api/redmine/RedmineApiClient.ts index ef276e9e..899acb68 100644 --- a/src/api/redmine/RedmineApiClient.ts +++ b/src/api/redmine/RedmineApiClient.ts @@ -1,4 +1,5 @@ import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; +import { getStorage, setStorage } from "@/hooks/useStorage"; import { Settings } from "@/provider/SettingsProvider"; import axios, { AxiosInstance, isAxiosError } from "axios"; import { formatISO } from "date-fns"; @@ -27,29 +28,53 @@ import { TVersion, } from "./types"; +type OAuth2Tokens = { + accessToken: string; + refreshToken: string; + expiresAt: number; +}; + export class RedmineApiClient { - private instance: AxiosInstance; public id = crypto.randomUUID(); + private instance: AxiosInstance; private auth?: Settings["auth"]; + private oauth2Tokens?: OAuth2Tokens; constructor(redmineURL: string, auth?: Settings["auth"]) { this.auth = auth; + this.instance = axios.create({ baseURL: redmineURL, headers: { ...(auth?.method === "apiKey" && { "X-Redmine-API-Key": auth.apiKey }), - ...(auth?.method === "oauth2" && { Authorization: `Bearer ${auth.oauth2?.accessToken}` }), + ...(auth?.method === "oauth2" && { Authorization: `Bearer ${this.oauth2Tokens?.accessToken ?? "loading"}` }), "Cache-Control": "no-cache, no-store, max-age=0", Expires: "0", }, }); - this.instance.interceptors.request.use((config) => { + + this.instance.interceptors.request.use(async (config) => { if (!config.baseURL) { throw new MissingRedmineConfigError(); } - if (auth?.method === "oauth2" && !auth.oauth2?.accessToken && config.url !== "/oauth/token") { - throw new RedmineAuthenticationError("Authorization required"); + + if (auth?.method === "oauth2" && config.url !== "/oauth/token") { + // Load tokens from storage if not already loaded + if (!this.oauth2Tokens) { + this.oauth2Tokens = await getStorage("oauth2-tokens", undefined); + if (!this.oauth2Tokens) { + throw new RedmineAuthenticationError("Authorization required"); + } + } + + // Refresh the access token if it's about to expire soon + if (this.oauth2Tokens.expiresAt && Date.now() >= this.oauth2Tokens.expiresAt - 3 * 60 * 1000) { + await this.refreshOAuth2AccessToken(); + } + + config.headers.Authorization = `Bearer ${this.oauth2Tokens.accessToken}`; } + return config; }); @@ -64,7 +89,7 @@ export class RedmineApiClient { (error) => { if (isAxiosError(error)) { if (error.response?.status === 401) { - const message = error.response.headers["www-authenticate"].match(/error_description="([^"]+)"/)?.[1]; + const message = error.response.headers["www-authenticate"]?.match(/error_description="([^"]+)"/)?.[1]; throw new RedmineAuthenticationError(message); } @@ -312,46 +337,64 @@ export class RedmineApiClient { } // Auth - getAuthorizeUrl({ clientId, redirectUri, scope }: { clientId: string; redirectUri: string; scope: string }): string { + private getOAuth2AuthorizeUrl({ redirectUri, scope }: { redirectUri: string; scope: string }): string { + if (!this.auth?.oauth2?.clientId) { + throw new RedmineAuthenticationError("OAuth2 Client ID is required to get authorize URL"); + } + return `${this.instance.defaults.baseURL}/oauth/authorize?${qs.stringify({ - client_id: clientId, + client_id: this.auth.oauth2.clientId, redirect_uri: redirectUri, response_type: "code", scope, })}`; } - async getAccessToken({ code, redirectUri, clientId, clientSecret }: { code: string; redirectUri: string; clientId: string; clientSecret: string }) { + private async getOAuth2AccessToken({ code, redirectUri }: { code: string; redirectUri: string }) { + if (!this.auth?.oauth2?.clientId || !this.auth?.oauth2?.clientSecret) { + throw new RedmineAuthenticationError("OAuth2 Client ID and Client Secret are required to get access token"); + } + return this.instance .post("/oauth/token", { grant_type: "authorization_code", code, redirect_uri: redirectUri, - client_id: clientId, - client_secret: clientSecret, + client_id: this.auth.oauth2.clientId, + client_secret: this.auth.oauth2.clientSecret, }) .then((res) => res.data); } - async refreshAccessToken({ refreshToken, clientId, clientSecret }: { refreshToken: string; clientId: string; clientSecret: string }) { - return this.instance + private async refreshOAuth2AccessToken() { + if (!this.auth?.oauth2?.clientId || !this.auth?.oauth2?.clientSecret) { + throw new RedmineAuthenticationError("OAuth2 Client ID and Client Secret are required to refresh access token"); + } + if (!this.oauth2Tokens?.refreshToken) { + throw new RedmineAuthenticationError("Refresh token is missing. Please re-authorize your Redmine account."); + } + + const tokens = await this.instance .post("/oauth/token", { grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: clientId, - client_secret: clientSecret, + refresh_token: this.oauth2Tokens.refreshToken, + client_id: this.auth.oauth2.clientId, + client_secret: this.auth.oauth2.clientSecret, }) .then((res) => res.data); + + // Store the new tokens + this.oauth2Tokens = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: (tokens.created_at + tokens.expires_in) * 1000, + }; + await setStorage("oauth2-tokens", this.oauth2Tokens); } async startOAuth2Authorization() { - if (!this.auth?.oauth2?.clientId || !this.auth?.oauth2?.clientSecret) { - throw new Error("OAuth2 Client ID and Client Secret are required for OAuth2 authentication"); - } - const redirectUri = browser.identity.getRedirectURL(); - const authorizeUrl = this.getAuthorizeUrl({ - clientId: this.auth.oauth2.clientId, + const authorizeUrl = this.getOAuth2AuthorizeUrl({ redirectUri, scope: "view_project search_project view_members view_issues view_time_entries", }); @@ -362,33 +405,33 @@ export class RedmineApiClient { url: authorizeUrl, }); if (!redirectURLString) { - throw new Error("No redirect URL received"); + throw new RedmineAuthenticationError("No redirect URL received"); } const redirectURL = new URL(redirectURLString); if (redirectURL.searchParams.get("error")) { if (redirectURL.searchParams.get("error") === "access_denied") { - throw new Error("Authorization was denied. Please allow access to connect your Redmine account."); + throw new RedmineAuthenticationError("Authorization was denied. Please allow access to connect your Redmine account."); } const errorDescription = redirectURL.searchParams.get("error_description") || "Unknown error"; - throw new Error(`Authorization error: ${errorDescription}`); + throw new RedmineAuthenticationError(`Authorization error: ${errorDescription}`); } const code = redirectURL.searchParams.get("code"); if (!code) { - throw new Error("Authorization code not found"); + throw new RedmineAuthenticationError("Authorization code is missing in the redirect URL"); } // Exchange the code for tokens - const tokenResponse = await this.getAccessToken({ + const tokenResponse = await this.getOAuth2AccessToken({ code, redirectUri, - clientId: this.auth.oauth2.clientId, - clientSecret: this.auth.oauth2.clientSecret, }); - return { + // Store the tokens + this.oauth2Tokens = { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAt: (tokenResponse.created_at + tokenResponse.expires_in) * 1000, }; + await setStorage("oauth2-tokens", this.oauth2Tokens); } } diff --git a/src/lang/en.json b/src/lang/en.json index adb66a7d..33113863 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -150,7 +150,6 @@ "settings.redmine.oauth2.setup.description": "To use OAuth2, you need to register an OAuth2 application in Redmine. This requires administrator permissions.", "settings.redmine.oauth2.setup.application-name": "Name", "settings.redmine.oauth2.setup.redirect-uri": "Redirect URI", - "settings.redmine.oauth2.token-expires": "Token expires on {date} at {time}", "settings.redmine.oauth2.authorize": "Authorize with OAuth2", "settings.redmine.oauth2.authorization-failed": "OAuth2 authorization failed: {error}", "settings.redmine.connecting": "Connecting...", diff --git a/src/provider/SettingsProvider.tsx b/src/provider/SettingsProvider.tsx index f1879324..e594d3c9 100644 --- a/src/provider/SettingsProvider.tsx +++ b/src/provider/SettingsProvider.tsx @@ -24,9 +24,6 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType(undefined); const redmineConnection = useTestRedmineConnection(redmineApiClient); @@ -497,13 +500,9 @@ const RedmineServerSection = withForm({ variant="outline" onClick={async () => { try { - const client = new RedmineApiClient(form.state.values.redmineURL, form.state.values.auth); - const result = await client.startOAuth2Authorization(); - form.setFieldValue("auth.oauth2.accessToken", result.accessToken); - form.setFieldValue("auth.oauth2.refreshToken", result.refreshToken); - form.setFieldValue("auth.oauth2.expiresAt", result.expiresAt); + await (redmineApiClient ?? defaultRedmineApi).startOAuth2Authorization(); } catch (error) { - toast.error(formatMessage({ id: "settings.redmine.oauth2.authorization-failed" }, { error: error instanceof Error ? error.message : String(error) })); + toast.error(formatMessage({ id: "settings.redmine.oauth2.authorization-failed" }, { error: getErrorMessage(error) })); } setRedmineApiClient(new RedmineApiClient(form.state.values.redmineURL, form.state.values.auth)); }} @@ -530,17 +529,6 @@ const RedmineServerSection = withForm({ } )} - {form.state.values.auth.method === "oauth2" && form.state.values.auth.oauth2?.expiresAt && ( - - {formatMessage( - { id: "settings.redmine.oauth2.token-expires" }, - { - date: formatDate(form.state.values.auth.oauth2.expiresAt, { dateStyle: "medium" }), - time: formatTime(form.state.values.auth.oauth2.expiresAt, { timeStyle: "short" }), - } - )} - - )} ) : undefined} From ec052ecb7dba6f94823eaa2447d7557f6003f14a Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:13:23 +0200 Subject: [PATCH 3/3] feat: Check token scopes/permissions --- src/api/redmine/RedmineApiClient.ts | 35 +++-- src/api/redmine/types.ts | 160 ++++++++++---------- src/components/issue/AddIssueNotesModal.tsx | 7 +- src/lang/en.json | 12 ++ src/provider/PermissionsProvider.tsx | 19 ++- src/provider/SettingsProvider.tsx | 24 +++ src/routes/settings.tsx | 34 ++++- 7 files changed, 197 insertions(+), 94 deletions(-) diff --git a/src/api/redmine/RedmineApiClient.ts b/src/api/redmine/RedmineApiClient.ts index 899acb68..b7dc1f75 100644 --- a/src/api/redmine/RedmineApiClient.ts +++ b/src/api/redmine/RedmineApiClient.ts @@ -14,7 +14,8 @@ import { TIssueStatus, TIssueTracker, TMembership, - TOAuthTokenResponse, + TOAuth2Scope, + TOAuth2TokenResponse, TPaginatedResponse, TProject, TReference, @@ -31,16 +32,17 @@ import { type OAuth2Tokens = { accessToken: string; refreshToken: string; + scope: string; expiresAt: number; }; export class RedmineApiClient { public id = crypto.randomUUID(); private instance: AxiosInstance; - private auth?: Settings["auth"]; + private auth: Settings["auth"]; private oauth2Tokens?: OAuth2Tokens; - constructor(redmineURL: string, auth?: Settings["auth"]) { + constructor(redmineURL: string, auth: Settings["auth"]) { this.auth = auth; this.instance = axios.create({ @@ -336,8 +338,8 @@ export class RedmineApiClient { return this.instance.get("/users/current.json?include=memberships").then((res) => res.data.user); } - // Auth - private getOAuth2AuthorizeUrl({ redirectUri, scope }: { redirectUri: string; scope: string }): string { + // OAuth2 authentication + private getOAuth2AuthorizeUrl({ redirectUri, scope }: { redirectUri: string; scope: TOAuth2Scope[] }): string { if (!this.auth?.oauth2?.clientId) { throw new RedmineAuthenticationError("OAuth2 Client ID is required to get authorize URL"); } @@ -346,7 +348,7 @@ export class RedmineApiClient { client_id: this.auth.oauth2.clientId, redirect_uri: redirectUri, response_type: "code", - scope, + scope: scope.join(" "), })}`; } @@ -356,7 +358,7 @@ export class RedmineApiClient { } return this.instance - .post("/oauth/token", { + .post("/oauth/token", { grant_type: "authorization_code", code, redirect_uri: redirectUri, @@ -375,7 +377,7 @@ export class RedmineApiClient { } const tokens = await this.instance - .post("/oauth/token", { + .post("/oauth/token", { grant_type: "refresh_token", refresh_token: this.oauth2Tokens.refreshToken, client_id: this.auth.oauth2.clientId, @@ -387,6 +389,7 @@ export class RedmineApiClient { this.oauth2Tokens = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, + scope: tokens.scope, expiresAt: (tokens.created_at + tokens.expires_in) * 1000, }; await setStorage("oauth2-tokens", this.oauth2Tokens); @@ -396,7 +399,16 @@ export class RedmineApiClient { const redirectUri = browser.identity.getRedirectURL(); const authorizeUrl = this.getOAuth2AuthorizeUrl({ redirectUri, - scope: "view_project search_project view_members view_issues view_time_entries", + scope: [ + // Default scopes + "view_project", + "search_project", + "view_members", + // Scopes enabled in settings + ...(Object.entries(this.auth.oauth2?.scopes || {}) + .filter(([, enabled]) => enabled) + .map(([s]) => s) as TOAuth2Scope[]), + ], }); // Authorize and get the code @@ -430,8 +442,13 @@ export class RedmineApiClient { this.oauth2Tokens = { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, + scope: tokenResponse.scope, expiresAt: (tokenResponse.created_at + tokenResponse.expires_in) * 1000, }; await setStorage("oauth2-tokens", this.oauth2Tokens); } + + getOAuth2TokenScopes() { + return this.auth?.method === "oauth2" && this.oauth2Tokens ? (this.oauth2Tokens.scope.split(" ") as TOAuth2Scope[]) : []; + } } diff --git a/src/api/redmine/types.ts b/src/api/redmine/types.ts index 1df092d5..fdb1fcdb 100644 --- a/src/api/redmine/types.ts +++ b/src/api/redmine/types.ts @@ -179,6 +179,84 @@ export type TUpdateTimeEntry = Partial & { }; // Roles and permissions +export type TPermission = + | "add_project" + | "edit_project" + | "close_project" + | "delete_project" + | "select_project_publicity" + | "select_project_modules" + | "manage_members" + | "manage_versions" + | "add_subprojects" + | "manage_public_queries" + | "save_queries" + | "view_issues" + | "add_issues" + | "edit_issues" + | "edit_own_issues" + | "copy_issues" + | "manage_issue_relations" + | "manage_subtasks" + | "set_issues_private" + | "set_own_issues_private" + | "add_issue_notes" + | "edit_issue_notes" + | "edit_own_issue_notes" + | "view_private_notes" + | "set_notes_private" + | "delete_issues" + | "view_issue_watchers" + | "add_issue_watchers" + | "delete_issue_watchers" + | "import_issues" + | "manage_categories" + | "view_time_entries" + | "log_time" + | "edit_time_entries" + | "edit_own_time_entries" + | "manage_project_activities" + | "log_time_for_other_users" + | "import_time_entries" + | "view_news" + | "manage_news" + | "comment_news" + | "view_documents" + | "add_documents" + | "edit_documents" + | "delete_documents" + | "view_files" + | "manage_files" + | "view_wiki_pages" + | "view_wiki_edits" + | "export_wiki_pages" + | "edit_wiki_pages" + | "rename_wiki_pages" + | "delete_wiki_pages" + | "delete_wiki_pages_attachments" + | "view_wiki_page_watchers" + | "add_wiki_page_watchers" + | "delete_wiki_page_watchers" + | "protect_wiki_pages" + | "manage_wiki" + | "view_changesets" + | "browse_repository" + | "commit_access" + | "manage_related_issues" + | "manage_repository" + | "view_messages" + | "add_messages" + | "edit_messages" + | "edit_own_messages" + | "delete_messages" + | "delete_own_messages" + | "view_message_watchers" + | "add_message_watchers" + | "delete_message_watchers" + | "manage_boards" + | "view_calendar" + | "view_gantt"; + export type TRole = { id: number; name: string; @@ -186,83 +264,7 @@ export type TRole = { issues_visibility?: "all" | "default" | "own"; // available since Redmine 4.0.0 time_entries_visibility?: "all" | "own"; // available since Redmine 4.0.0 users_visibility?: "all" | "members_of_visible_projects"; // available since Redmine 4.0.0 - permissions: ( - | "add_project" - | "edit_project" - | "close_project" - | "delete_project" - | "select_project_modules" - | "manage_members" - | "manage_versions" - | "add_subprojects" - | "manage_public_queries" - | "save_queries" - | "view_issues" - | "add_issues" - | "edit_issues" - | "edit_own_issues" - | "copy_issues" - | "manage_issue_relations" - | "manage_subtasks" - | "set_issues_private" - | "set_own_issues_private" - | "add_issue_notes" - | "edit_issue_notes" - | "edit_own_issue_notes" - | "view_private_notes" - | "set_notes_private" - | "delete_issues" - | "view_issue_watchers" - | "add_issue_watchers" - | "delete_issue_watchers" - | "import_issues" - | "manage_categories" - | "view_time_entries" - | "log_time" - | "edit_time_entries" - | "edit_own_time_entries" - | "manage_project_activities" - | "log_time_for_other_users" - | "import_time_entries" - | "view_news" - | "manage_news" - | "comment_news" - | "view_documents" - | "add_documents" - | "edit_documents" - | "delete_documents" - | "view_files" - | "manage_files" - | "view_wiki_pages" - | "view_wiki_edits" - | "export_wiki_pages" - | "edit_wiki_pages" - | "rename_wiki_pages" - | "delete_wiki_pages" - | "delete_wiki_pages_attachments" - | "view_wiki_page_watchers" - | "add_wiki_page_watchers" - | "delete_wiki_page_watchers" - | "protect_wiki_pages" - | "manage_wiki" - | "view_changesets" - | "browse_repository" - | "commit_access" - | "manage_related_issues" - | "manage_repository" - | "view_messages" - | "add_messages" - | "edit_messages" - | "edit_own_messages" - | "delete_messages" - | "delete_own_messages" - | "view_message_watchers" - | "add_message_watchers" - | "delete_message_watchers" - | "manage_boards" - | "view_calendar" - | "view_gantt" - )[]; + permissions: TPermission[]; }; export type TUser = { @@ -298,7 +300,9 @@ export type TPaginatedResponse = { limit: number; } & T; -export type TOAuthTokenResponse = { +export type TOAuth2Scope = "view_project" | "search_project" | "view_members" | TPermission; + +export type TOAuth2TokenResponse = { token_type: string; access_token: string; refresh_token: string; diff --git a/src/components/issue/AddIssueNotesModal.tsx b/src/components/issue/AddIssueNotesModal.tsx index a9a3eed8..2014a67c 100644 --- a/src/components/issue/AddIssueNotesModal.tsx +++ b/src/components/issue/AddIssueNotesModal.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/no-children-prop */ import { redmineIssuesQueries } from "@/api/redmine/queries/issues"; +import { usePermissions } from "@/provider/PermissionsProvider"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useIntl } from "react-intl"; import { z } from "zod"; @@ -30,6 +31,8 @@ const AddIssueNotesModal = ({ issue, onClose, onSuccess }: PropTypes) => { const redmineApi = useRedmineApi(); const queryClient = useQueryClient(); + const { hasProjectPermission } = usePermissions(); + const updateIssueMutation = useMutation({ mutationFn: (data: TUpdateIssue) => redmineApi.updateIssue(issue.id, data), onSuccess: () => { @@ -69,7 +72,9 @@ const AddIssueNotesModal = ({ issue, onClose, onSuccess }: PropTypes) => { )} /> - } /> + {hasProjectPermission(issue.project.id, "set_notes_private") && ( + } /> + )} diff --git a/src/lang/en.json b/src/lang/en.json index 33113863..a5eb6951 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -152,6 +152,18 @@ "settings.redmine.oauth2.setup.redirect-uri": "Redirect URI", "settings.redmine.oauth2.authorize": "Authorize with OAuth2", "settings.redmine.oauth2.authorization-failed": "OAuth2 authorization failed: {error}", + "settings.redmine.oauth2.scopes": "Permissions", + "settings.redmine.oauth2.scopes.description": "Select permissions depending on the features you want to use", + "settings.redmine.oauth2.scopes.view_issues": "View issues", + "settings.redmine.oauth2.scopes.add_issues": "Create issues", + "settings.redmine.oauth2.scopes.edit_issues": "Edit issues", + "settings.redmine.oauth2.scopes.edit_own_issues": "Edit own issues", + "settings.redmine.oauth2.scopes.add_issue_notes": "Add notes to issues", + "settings.redmine.oauth2.scopes.set_notes_private": "Mark notes as private", + "settings.redmine.oauth2.scopes.view_time_entries": "View time entries", + "settings.redmine.oauth2.scopes.log_time": "Log time", + "settings.redmine.oauth2.scopes.edit_own_time_entries": "Edit own time entries", + "settings.redmine.oauth2.scopes.log_time_for_other_users": "Log time for other users", "settings.redmine.connecting": "Connecting...", "settings.redmine.connection-failed": "Connection failed", "settings.redmine.connection-successful": "Connection successful!", diff --git a/src/provider/PermissionsProvider.tsx b/src/provider/PermissionsProvider.tsx index 1e541783..7d209cc1 100644 --- a/src/provider/PermissionsProvider.tsx +++ b/src/provider/PermissionsProvider.tsx @@ -3,18 +3,22 @@ import { useRedminePaginatedInfiniteQuery } from "@/api/redmine/hooks/useRedmine import { redmineProjectsQuery } from "@/api/redmine/queries/projects"; import { redmineRoleQuery } from "@/api/redmine/queries/roles"; import { useRedmineApi } from "@/provider/RedmineApiProvider"; +import { useSettings } from "@/provider/SettingsProvider"; import { combineAggregateQueries } from "@/utils/query"; import { useQueries } from "@tanstack/react-query"; import { ReactNode, createContext, use } from "react"; -import { TProject, TRole, TUser } from "../api/redmine/types"; +import { TOAuth2Scope, TPermission, TProject, TRole, TUser } from "../api/redmine/types"; type PermissionContextType = { - hasProjectPermission: (projectId: number, permission: TRole["permissions"][number]) => boolean; + getProjectRoles: (projectId: number) => TRole[]; + hasProjectPermission: (projectId: number, permission: TPermission) => boolean; + hasOAuth2Scope: (scope: TOAuth2Scope) => boolean; }; const PermissionContext = createContext(null); const PermissionProvider = ({ children }: { children: ReactNode }) => { + const { settings } = useSettings(); const redmineApi = useRedmineApi(); const { data: me } = useRedmineCurrentUser(); @@ -33,14 +37,21 @@ const PermissionProvider = ({ children }: { children: ReactNode }) => { }); const projectRolesMap = buildProjectRolesMap({ user: me, roles: rolesQuery.data, projects: projectsQuery.data }); + const tokenScopes = redmineApi.getOAuth2TokenScopes(); - const hasProjectPermission = (projectId: number, permission: TRole["permissions"][number]): boolean => - me?.admin || projectRolesMap.get(projectId)?.some((r) => r.permissions.includes(permission)) || false; + const getProjectRoles = (projectId: number): TRole[] => projectRolesMap.get(projectId) ?? []; + const hasOAuth2Scope = (scope: TOAuth2Scope): boolean => tokenScopes.includes(scope); + const hasProjectPermission = (projectId: number, permission: TPermission): boolean => + (me?.admin && settings.auth.method === "apiKey") || + (getProjectRoles(projectId).some((r) => r.permissions.includes(permission)) && (settings.auth.method === "apiKey" || hasOAuth2Scope(permission))) || + false; return ( {children} diff --git a/src/provider/SettingsProvider.tsx b/src/provider/SettingsProvider.tsx index e594d3c9..2a7dee65 100644 --- a/src/provider/SettingsProvider.tsx +++ b/src/provider/SettingsProvider.tsx @@ -24,6 +24,18 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType; required: boolean }[]; + const RedmineServerSection = withForm({ defaultValues: {} as Settings, validators: { @@ -466,6 +481,21 @@ const RedmineServerSection = withForm({ /> )} /> + +
+ + + {formatMessage({ id: "settings.redmine.oauth2.scopes" })} + + + + {formatMessage({ id: "settings.redmine.oauth2.scopes.description" })} + + {OAUTH2_SCOPES.map(({ scope, label, required }) => ( + } /> + ))} + +
) }