Skip to content

Commit 0374966

Browse files
committed
feat: Add support for redmine oauth2 login flow (WIP)
1 parent cd60ec7 commit 0374966

10 files changed

Lines changed: 343 additions & 55 deletions

File tree

src/api/redmine/RedmineApiClient.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import axios, { AxiosInstance } from "axios";
1+
import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError";
2+
import { Settings } from "@/provider/SettingsProvider";
3+
import axios, { AxiosInstance, isAxiosError } from "axios";
24
import { formatISO } from "date-fns";
35
import qs from "qs";
6+
import { browser } from "wxt/browser";
47
import { MissingRedmineConfigError } from "./MissingRedmineConfigError";
58
import {
69
TCreateIssue,
@@ -10,6 +13,7 @@ import {
1013
TIssueStatus,
1114
TIssueTracker,
1215
TMembership,
16+
TOAuthTokenResponse,
1317
TPaginatedResponse,
1418
TProject,
1519
TReference,
@@ -26,12 +30,15 @@ import {
2630
export class RedmineApiClient {
2731
private instance: AxiosInstance;
2832
public id = crypto.randomUUID();
33+
private auth?: Settings["auth"];
2934

30-
constructor(redmineURL: string, redmineApiKey: string) {
35+
constructor(redmineURL: string, auth?: Settings["auth"]) {
36+
this.auth = auth;
3137
this.instance = axios.create({
3238
baseURL: redmineURL,
3339
headers: {
34-
"X-Redmine-API-Key": redmineApiKey,
40+
...(auth?.method === "apiKey" && { "X-Redmine-API-Key": auth.apiKey }),
41+
...(auth?.method === "oauth2" && { Authorization: `Bearer ${auth.oauth2?.accessToken}` }),
3542
"Cache-Control": "no-cache, no-store, max-age=0",
3643
Expires: "0",
3744
},
@@ -40,8 +47,12 @@ export class RedmineApiClient {
4047
if (!config.baseURL) {
4148
throw new MissingRedmineConfigError();
4249
}
50+
if (auth?.method === "oauth2" && !auth.oauth2?.accessToken && config.url !== "/oauth/token") {
51+
throw new RedmineAuthenticationError("Authorization required");
52+
}
4353
return config;
4454
});
55+
4556
this.instance.interceptors.response.use(
4657
(response) => {
4758
const contentType = response.headers["content-type"];
@@ -51,11 +62,15 @@ export class RedmineApiClient {
5162
return response;
5263
},
5364
(error) => {
54-
if (error.response?.status === 401) {
55-
throw new Error("Unauthorized");
56-
}
57-
if (error.response?.status === 403) {
58-
throw new Error("Forbidden");
65+
if (isAxiosError(error)) {
66+
if (error.response?.status === 401) {
67+
const message = error.response.headers["www-authenticate"].match(/error_description="([^"]+)"/)?.[1];
68+
throw new RedmineAuthenticationError(message);
69+
}
70+
71+
if (error.response?.status === 403) {
72+
throw new Error("Forbidden");
73+
}
5974
}
6075
return Promise.reject(error);
6176
}
@@ -295,4 +310,85 @@ export class RedmineApiClient {
295310
async getCurrentUser(): Promise<TUser> {
296311
return this.instance.get("/users/current.json?include=memberships").then((res) => res.data.user);
297312
}
313+
314+
// Auth
315+
getAuthorizeUrl({ clientId, redirectUri, scope }: { clientId: string; redirectUri: string; scope: string }): string {
316+
return `${this.instance.defaults.baseURL}/oauth/authorize?${qs.stringify({
317+
client_id: clientId,
318+
redirect_uri: redirectUri,
319+
response_type: "code",
320+
scope,
321+
})}`;
322+
}
323+
324+
async getAccessToken({ code, redirectUri, clientId, clientSecret }: { code: string; redirectUri: string; clientId: string; clientSecret: string }) {
325+
return this.instance
326+
.post<TOAuthTokenResponse>("/oauth/token", {
327+
grant_type: "authorization_code",
328+
code,
329+
redirect_uri: redirectUri,
330+
client_id: clientId,
331+
client_secret: clientSecret,
332+
})
333+
.then((res) => res.data);
334+
}
335+
336+
async refreshAccessToken({ refreshToken, clientId, clientSecret }: { refreshToken: string; clientId: string; clientSecret: string }) {
337+
return this.instance
338+
.post<TOAuthTokenResponse>("/oauth/token", {
339+
grant_type: "refresh_token",
340+
refresh_token: refreshToken,
341+
client_id: clientId,
342+
client_secret: clientSecret,
343+
})
344+
.then((res) => res.data);
345+
}
346+
347+
async startOAuth2Authorization() {
348+
if (!this.auth?.oauth2?.clientId || !this.auth?.oauth2?.clientSecret) {
349+
throw new Error("OAuth2 Client ID and Client Secret are required for OAuth2 authentication");
350+
}
351+
352+
const redirectUri = browser.identity.getRedirectURL();
353+
const authorizeUrl = this.getAuthorizeUrl({
354+
clientId: this.auth.oauth2.clientId,
355+
redirectUri,
356+
scope: "view_project search_project view_members view_issues view_time_entries",
357+
});
358+
359+
// Authorize and get the code
360+
const redirectURLString = await browser.identity.launchWebAuthFlow({
361+
interactive: true,
362+
url: authorizeUrl,
363+
});
364+
if (!redirectURLString) {
365+
throw new Error("No redirect URL received");
366+
}
367+
const redirectURL = new URL(redirectURLString);
368+
if (redirectURL.searchParams.get("error")) {
369+
if (redirectURL.searchParams.get("error") === "access_denied") {
370+
throw new Error("Authorization was denied. Please allow access to connect your Redmine account.");
371+
}
372+
const errorDescription = redirectURL.searchParams.get("error_description") || "Unknown error";
373+
throw new Error(`Authorization error: ${errorDescription}`);
374+
}
375+
const code = redirectURL.searchParams.get("code");
376+
if (!code) {
377+
throw new Error("Authorization code not found");
378+
}
379+
380+
// Exchange the code for tokens
381+
const tokenResponse = await this.getAccessToken({
382+
code,
383+
redirectUri,
384+
clientId: this.auth.oauth2.clientId,
385+
clientSecret: this.auth.oauth2.clientSecret,
386+
});
387+
388+
return {
389+
accessToken: tokenResponse.access_token,
390+
refreshToken: tokenResponse.refresh_token,
391+
expiresAt: (tokenResponse.created_at + tokenResponse.expires_in) * 1000,
392+
};
393+
}
298394
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class RedmineAuthenticationError extends Error {
2+
constructor(message = "Unauthorized") {
3+
super(message);
4+
this.name = RedmineAuthenticationError.name;
5+
}
6+
}

src/api/redmine/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,12 @@ export type TPaginatedResponse<T> = {
297297
offset: number;
298298
limit: number;
299299
} & T;
300+
301+
export type TOAuthTokenResponse = {
302+
token_type: string;
303+
access_token: string;
304+
refresh_token: string;
305+
expires_in: number;
306+
scope: string;
307+
created_at: number;
308+
};

src/components/error/ErrorComponent.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MissingRedmineConfigError } from "@/api/redmine/MissingRedmineConfigError";
2+
import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError";
23
import { getErrorMessage } from "@/utils/error";
34
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
45
import { ErrorComponentProps } from "@tanstack/react-router";
@@ -20,7 +21,9 @@ export function ErrorComponent({ error, reset: resetPage }: ErrorComponentProps)
2021
? formatMessage({ id: "general.error.api-error" })
2122
: error instanceof MissingRedmineConfigError
2223
? formatMessage({ id: "general.error.missing-redmine-configuration" })
23-
: formatMessage({ id: "general.error.unknown-error" }, { name: error.name })}
24+
: error instanceof RedmineAuthenticationError
25+
? formatMessage({ id: "general.error.redmine-authentication-error" })
26+
: formatMessage({ id: "general.error.unknown-error" }, { name: error.name })}
2427
</AlertTitle>
2528
{!(error instanceof MissingRedmineConfigError) && (
2629
<AlertDescription>

src/lang/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,21 @@
138138
"settings.redmine.url": "Redmine URL",
139139
"settings.redmine.url.validation.required": "URL is required",
140140
"settings.redmine.url.validation.valid-url": "Enter a valid URL",
141+
"settings.redmine.auth-method": "Authentication method",
142+
"settings.redmine.auth-method.api-key": "API Key",
143+
"settings.redmine.auth-method.oauth2": "OAuth2",
141144
"settings.redmine.api-key": "Redmine API-Key",
142145
"settings.redmine.api-key.validation.required": "API-Key is required",
143146
"settings.redmine.api-key.hint": "Where can I find my API-Key? <link>here</link>",
147+
"settings.redmine.oauth2.client-id": "Client ID",
148+
"settings.redmine.oauth2.client-secret": "Client Secret",
149+
"settings.redmine.oauth2.setup": "OAuth2 setup",
150+
"settings.redmine.oauth2.setup.description": "To use OAuth2, you need to register an <link>OAuth2 application</link> in Redmine. This requires administrator permissions.",
151+
"settings.redmine.oauth2.setup.application-name": "Name",
152+
"settings.redmine.oauth2.setup.redirect-uri": "Redirect URI",
153+
"settings.redmine.oauth2.token-expires": "Token expires on {date} at {time}",
154+
"settings.redmine.oauth2.authorize": "Authorize with OAuth2",
155+
"settings.redmine.oauth2.authorization-failed": "OAuth2 authorization failed: {error}",
144156
"settings.redmine.connecting": "Connecting...",
145157
"settings.redmine.connection-failed": "Connection failed",
146158
"settings.redmine.connection-successful": "Connection successful!",
@@ -189,6 +201,7 @@
189201
"general.retry": "Retry",
190202
"general.error.api-error": "API-Error",
191203
"general.error.missing-redmine-configuration": "Redmine URL is not configured",
204+
"general.error.redmine-authentication-error": "Redmine authentication error",
192205
"general.error.unknown-error": "Unknown error: {name}",
193206
"general.error.page-not-found": "Page not found",
194207

src/provider/QueryClientProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MissingRedmineConfigError } from "@/api/redmine/MissingRedmineConfigError";
2+
import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError";
23
import { getErrorMessage } from "@/utils/error";
34
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
45
import { MutationCache, QueryCache, QueryClient, useIsRestoring } from "@tanstack/react-query";
@@ -45,6 +46,7 @@ export const queryClient = new QueryClient({
4546

4647
// Skip if Redmine URL is not configured
4748
if (error instanceof MissingRedmineConfigError) return;
49+
if (error instanceof RedmineAuthenticationError) return;
4850

4951
if (!isAxiosError(error)) {
5052
toast.error(

src/provider/RedmineApiProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const RedmineApiContext = createContext<RedmineApiClient | null>(null);
77
const RedmineApiProvider = ({ children }: { children: ReactNode }) => {
88
const { settings } = useSettings();
99

10-
return <RedmineApiContext value={new RedmineApiClient(settings.redmineURL, settings.redmineApiKey)}>{children}</RedmineApiContext>;
10+
return <RedmineApiContext value={new RedmineApiClient(settings.redmineURL, settings.auth)}>{children}</RedmineApiContext>;
1111
};
1212

1313
export const useRedmineApi = () => use(RedmineApiContext)!;

src/provider/SettingsProvider.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,47 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType<t
1313
.string(formatMessage?.({ id: "settings.redmine.url.validation.required" }))
1414
.nonempty(formatMessage?.({ id: "settings.redmine.url.validation.required" }))
1515
.regex(/^(http|https):\/\/[\w\-.]+(\.\w+)*(:[0-9]+)?[\w\-/]*\/?$/, formatMessage?.({ id: "settings.redmine.url.validation.valid-url" })),
16-
redmineApiKey: z.string().nonempty(formatMessage?.({ id: "settings.redmine.api-key.validation.required" })),
16+
/**
17+
* @deprecated Use `auth.apiKey` instead.
18+
*/
19+
redmineApiKey: z.string().optional(),
20+
auth: z.object({
21+
method: z.enum(["apiKey", "oauth2"]),
22+
apiKey: z.string().optional(),
23+
oauth2: z
24+
.object({
25+
clientId: z.string(),
26+
clientSecret: z.string(),
27+
accessToken: z.string().optional(),
28+
refreshToken: z.string().optional(),
29+
expiresAt: z.number().optional(),
30+
})
31+
.optional(),
32+
}),
1733
features: z.object({
1834
autoPauseOnSwitch: z.boolean(),
19-
roundTimeNearestQuarterHour: z.boolean().optional(), // ! Legacy
20-
roundToNearestInterval: z.boolean().optional(), // ! Legacy
35+
/**
36+
* @deprecated Use `roundToInterval` with `roundingMode: "nearest"` and `roundingInterval: 15` instead.
37+
*/
38+
roundTimeNearestQuarterHour: z.boolean().optional(),
39+
/**
40+
* @deprecated Use `roundToInterval` with `roundingMode: "nearest"` instead.
41+
*/
42+
roundToNearestInterval: z.boolean().optional(),
2143
roundToInterval: z.boolean(),
2244
roundingMode: z.enum(["down", "nearest", "up"]),
2345
roundingInterval: z
2446
.int(formatMessage?.({ id: "settings.features.rounding-interval.validation.required" }))
2547
.min(1, formatMessage?.({ id: "settings.features.rounding-interval.validation.greater-than-zero" }))
2648
.max(60, formatMessage?.({ id: "settings.features.rounding-interval.validation.less-than-or-equals-sixty" })),
27-
addNotes: z.boolean().optional(), // ! Legacy
28-
cacheComments: z.boolean().optional(), // ! Legacy
49+
/**
50+
* @deprecated This setting has no effect and will be removed in a future version.
51+
*/
52+
addNotes: z.boolean().optional(),
53+
/**
54+
* @deprecated Use `persistentComments` instead.
55+
*/
56+
cacheComments: z.boolean().optional(),
2957
persistentComments: z.boolean(),
3058
showCurrentIssueTimer: z.boolean(),
3159
}),
@@ -48,7 +76,14 @@ export type Settings = z.infer<ReturnType<typeof settingsSchema>>;
4876
const defaultSettings: Settings = {
4977
language: "browser",
5078
redmineURL: "",
51-
redmineApiKey: "",
79+
auth: {
80+
method: "apiKey",
81+
apiKey: "",
82+
oauth2: {
83+
clientId: "",
84+
clientSecret: "",
85+
},
86+
},
5287
features: {
5388
autoPauseOnSwitch: true,
5489
roundToInterval: false,
@@ -97,6 +132,13 @@ export const runSettingsMigration = async () => {
97132
settings.features.addNotes = undefined;
98133
}
99134

135+
if (settings.redmineApiKey) {
136+
settings.auth = {
137+
method: "apiKey",
138+
apiKey: settings.redmineApiKey,
139+
};
140+
}
141+
100142
if (JSON.stringify(settings) !== JSON.stringify(settingsData)) {
101143
await setStorage("settings", settings);
102144
}

0 commit comments

Comments
 (0)