diff --git a/README.md b/README.md index 7113dcdc..f2bde5c8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ At least **Redmine version `3.0` or higher** required. Recommended version `5.0` | Feature | Unsupported Redmine version | | --------------------------------------------------------------------------------- | --------------------------- | +| OAuth2 authentication method | `< 6.1.0` | | Show only **enabled** issue field for selected tracker when _creating new issues_ | `< 5.0.0` | | Show only **allowed statuses** when _updating issue_ | `< 5.0.0` | | Show spent vs estimated hours | `< 5.0.0` | diff --git a/src/api/redmine/RedmineApiClient.ts b/src/api/redmine/RedmineApiClient.ts index 2e7bcd19..6e32e75b 100644 --- a/src/api/redmine/RedmineApiClient.ts +++ b/src/api/redmine/RedmineApiClient.ts @@ -1,6 +1,10 @@ -import axios, { AxiosInstance } from "axios"; +import { RedmineAuthenticationError } from "@/api/redmine/RedmineAuthenticationError"; +import { getStorage, removeStorage, setStorage } from "@/hooks/useStorage"; +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 +14,8 @@ import { TIssueStatus, TIssueTracker, TMembership, + TOAuth2Scope, + TOAuth2TokenResponse, TPaginatedResponse, TProject, TReference, @@ -23,25 +29,57 @@ import { TVersion, } from "./types"; +type OAuth2Tokens = { + accessToken: string; + refreshToken: string; + scope: string; + expiresAt: number; +}; + export class RedmineApiClient { - private instance: AxiosInstance; public id = crypto.randomUUID(); + private instance: AxiosInstance; + private auth: Settings["redmine"]["auth"]; + private oauth2Tokens?: OAuth2Tokens; + + constructor(redmineURL: string, auth: Settings["redmine"]["auth"]) { + this.auth = auth; - constructor(redmineURL: string, redmineApiKey: string) { 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 ${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" && 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; }); + this.instance.interceptors.response.use( (response) => { const contentType = response.headers["content-type"]; @@ -51,11 +89,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 +337,136 @@ export class RedmineApiClient { async getCurrentUser(): Promise { return this.instance.get("/users/current.json?include=memberships").then((res) => res.data.user); } + + // OAuth2 authentication + private getOAuth2AuthorizeUrl({ redirectUri, scope }: { redirectUri: string; scope: TOAuth2Scope[] }): string { + if (this.auth.method !== "oauth2") { + throw new RedmineAuthenticationError("OAuth2 authentication method is not enabled"); + } + 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: this.auth.oauth2.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: scope.join(" "), + })}`; + } + + private async getOAuth2AccessToken({ code, redirectUri }: { code: string; redirectUri: string }) { + if (this.auth.method !== "oauth2") { + throw new RedmineAuthenticationError("OAuth2 authentication method is not enabled"); + } + 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: this.auth.oauth2.clientId, + client_secret: this.auth.oauth2.clientSecret, + }) + .then((res) => res.data); + } + + private async refreshOAuth2AccessToken() { + if (this.auth.method !== "oauth2") { + throw new RedmineAuthenticationError("OAuth2 authentication method is not enabled"); + } + 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: 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, + scope: tokens.scope, + expiresAt: (tokens.created_at + tokens.expires_in) * 1000, + }; + await setStorage("oauth2-tokens", this.oauth2Tokens); + } + + async startOAuth2Authorization() { + if (this.auth.method !== "oauth2") { + throw new RedmineAuthenticationError("OAuth2 authentication method is not enabled"); + } + + const redirectUri = browser.identity.getRedirectURL(); + const authorizeUrl = this.getOAuth2AuthorizeUrl({ + redirectUri, + 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 + const redirectURLString = await browser.identity.launchWebAuthFlow({ + interactive: true, + url: authorizeUrl, + }); + if (!redirectURLString) { + 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 RedmineAuthenticationError("Authorization was denied. Please allow access to connect your Redmine account."); + } + const errorDescription = redirectURL.searchParams.get("error_description") || "Unknown error"; + throw new RedmineAuthenticationError(`Authorization error: ${errorDescription}`); + } + const code = redirectURL.searchParams.get("code"); + if (!code) { + throw new RedmineAuthenticationError("Authorization code is missing in the redirect URL"); + } + + // Exchange the code for tokens + const tokenResponse = await this.getOAuth2AccessToken({ + code, + redirectUri, + }); + + // Store the tokens + 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); + } + + async clearOAuth2Tokens() { + this.oauth2Tokens = undefined; + await removeStorage("oauth2-tokens"); + } + + getOAuth2TokenScopes() { + return this.auth.method === "oauth2" && this.oauth2Tokens ? (this.oauth2Tokens.scope.split(" ") as TOAuth2Scope[]) : []; + } } 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/hooks/useTestRedmineConnection.ts b/src/api/redmine/hooks/useTestRedmineConnection.ts index 5424d936..9ba0738a 100644 --- a/src/api/redmine/hooks/useTestRedmineConnection.ts +++ b/src/api/redmine/hooks/useTestRedmineConnection.ts @@ -1,9 +1,11 @@ import { RedmineApiClient } from "@/api/redmine/RedmineApiClient"; import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { useRedmineApi } from "../../../provider/RedmineApiProvider"; -export const useTestRedmineConnection = (customRedmineApiClient?: RedmineApiClient) => { +export const useTestRedmineConnection = () => { const defaultRedmineApi = useRedmineApi(); + const [customRedmineApiClient, setRedmineApiClient] = useState(undefined); const redmineApiClient = customRedmineApiClient ?? defaultRedmineApi; const myUserQuery = useQuery({ @@ -22,5 +24,8 @@ export const useTestRedmineConnection = (customRedmineApiClient?: RedmineApiClie isLoading: myUserQuery.isLoading, isError: myUserQuery.isError, error: myUserQuery.error, + refresh: myUserQuery.refetch, + redmineApiClient, + setRedmineApiClient, }; }; diff --git a/src/api/redmine/types.ts b/src/api/redmine/types.ts index 231c5522..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 = { @@ -297,3 +299,14 @@ export type TPaginatedResponse = { offset: number; limit: number; } & T; + +export type TOAuth2Scope = "view_project" | "search_project" | "view_members" | TPermission; + +export type TOAuth2TokenResponse = { + 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/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/de.json b/src/lang/de.json index 106e2042..245b2716 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -138,9 +138,34 @@ "settings.redmine.url": "Redmine URL", "settings.redmine.url.validation.required": "URL wird benötigt", "settings.redmine.url.validation.valid-url": "Geben Sie eine valide URL ein", - "settings.redmine.api-key": "Redmine API-Schlüssel", - "settings.redmine.api-key.validation.required": "API-Schlüssel wird benötigt", - "settings.redmine.api-key.hint": "Wo finde ich meinen API-Schlüssel? hier", + "settings.redmine.auth.method": "Authentifizierungsmethode", + "settings.redmine.auth.method.api-key": "API Key", + "settings.redmine.auth.method.oauth2": "OAuth2 (experimentell)", + "settings.redmine.auth.api-key": "Redmine API-Schlüssel", + "settings.redmine.auth.api-key.validation.required": "API-Schlüssel wird benötigt", + "settings.redmine.auth.api-key.hint": "Wo finde ich meinen API-Schlüssel? hier", + "settings.redmine.auth.oauth2.setup": "OAuth2 Einrichtung (seit Redmine 6.1.0)", + "settings.redmine.auth.oauth2.setup.description": "Sie müssen eine OAuth2-Anwendung in Ihrer Redmine-Instanz registrieren. Dies erfordert Administratorrechte.", + "settings.redmine.auth.oauth2.setup.application-name": "Name", + "settings.redmine.auth.oauth2.setup.redirect-uri": "Weiterleitungs-URI", + "settings.redmine.auth.oauth2.client-id": "Anwendungs-ID", + "settings.redmine.auth.oauth2.client-id.validation.required": "Anwendungs-ID wird benötigt", + "settings.redmine.auth.oauth2.client-secret": "Anwendungs-Geheimnis", + "settings.redmine.auth.oauth2.client-secret.validation.required": "Anwendungs-Geheimnis wird benötigt", + "settings.redmine.auth.oauth2.scopes": "Scopes/Berechtigungen", + "settings.redmine.auth.oauth2.scopes.description": "Wählen Sie die Berechtigungen abhängig von den Funktionen, die Sie verwenden möchten", + "settings.redmine.auth.oauth2.scopes.view_issues": "Tickets anzeigen", + "settings.redmine.auth.oauth2.scopes.add_issues": "Tickets hinzufügen", + "settings.redmine.auth.oauth2.scopes.edit_issues": "Tickets bearbeiten", + "settings.redmine.auth.oauth2.scopes.edit_own_issues": "Eigene Tickets bearbeiten", + "settings.redmine.auth.oauth2.scopes.add_issue_notes": "Kommentare hinzufügen", + "settings.redmine.auth.oauth2.scopes.set_notes_private": "Kommentare als privat markieren", + "settings.redmine.auth.oauth2.scopes.view_time_entries": "Gebuchte Aufwände anzeigen", + "settings.redmine.auth.oauth2.scopes.log_time": "Aufwände buchen", + "settings.redmine.auth.oauth2.scopes.edit_own_time_entries": "Selbst gebuchte Aufwände bearbeiten", + "settings.redmine.auth.oauth2.scopes.log_time_for_other_users": "Aufwände für andere Benutzer buchen", + "settings.redmine.auth.oauth2.authorize": "Mit OAuth2 autorisieren", + "settings.redmine.auth.oauth2.authorization-failed": "OAuth2-Autorisierung fehlgeschlagen: {error}", "settings.redmine.connecting": "Verbinde...", "settings.redmine.connection-failed": "Verbindung fehlgeschlagen", "settings.redmine.connection-successful": "Verbindung erfolgreich!", @@ -192,6 +217,7 @@ "general.retry": "Wiederholen", "general.error.api-error": "API-Fehler", "general.error.missing-redmine-configuration": "Redmine URL ist nicht konfiguriert", + "general.error.redmine-authentication-error": "Redmine Authentifizierungsfehler", "general.error.unknown-error": "Unbekannter Fehler: {name}", "general.error.page-not-found": "Seite nicht gefunden", diff --git a/src/lang/en.json b/src/lang/en.json index e57aa313..73d071ca 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -138,9 +138,34 @@ "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.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.auth.method": "Authentication method", + "settings.redmine.auth.method.api-key": "API Key", + "settings.redmine.auth.method.oauth2": "OAuth2 (experimental)", + "settings.redmine.auth.api-key": "Redmine API-Key", + "settings.redmine.auth.api-key.validation.required": "API-Key is required", + "settings.redmine.auth.api-key.hint": "Where can I find my API-Key? here", + "settings.redmine.auth.oauth2.setup": "OAuth2 setup (since Redmine 6.1.0)", + "settings.redmine.auth.oauth2.setup.description": "You need to register an OAuth2 application in your Redmine instance. This requires administrator permissions.", + "settings.redmine.auth.oauth2.setup.application-name": "Name", + "settings.redmine.auth.oauth2.setup.redirect-uri": "Redirect URI", + "settings.redmine.auth.oauth2.client-id": "Client ID", + "settings.redmine.auth.oauth2.client-id.validation.required": "Client ID is required", + "settings.redmine.auth.oauth2.client-secret": "Client Secret", + "settings.redmine.auth.oauth2.client-secret.validation.required": "Client Secret is required", + "settings.redmine.auth.oauth2.scopes": "Scopes/Permissions", + "settings.redmine.auth.oauth2.scopes.description": "Select permissions depending on the features you want to use", + "settings.redmine.auth.oauth2.scopes.view_issues": "View issues", + "settings.redmine.auth.oauth2.scopes.add_issues": "Add issues", + "settings.redmine.auth.oauth2.scopes.edit_issues": "Edit issues", + "settings.redmine.auth.oauth2.scopes.edit_own_issues": "Edit own issues", + "settings.redmine.auth.oauth2.scopes.add_issue_notes": "Add issue notes", + "settings.redmine.auth.oauth2.scopes.set_notes_private": "Mark notes as private", + "settings.redmine.auth.oauth2.scopes.view_time_entries": "View spent time", + "settings.redmine.auth.oauth2.scopes.log_time": "Log spent time", + "settings.redmine.auth.oauth2.scopes.edit_own_time_entries": "Edit own time logs", + "settings.redmine.auth.oauth2.scopes.log_time_for_other_users": "Log spent time for other users", + "settings.redmine.auth.oauth2.authorize": "Authorize with OAuth2", + "settings.redmine.auth.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 +217,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/lang/fr.json b/src/lang/fr.json index 8c8748a1..eae7d186 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -138,9 +138,34 @@ "settings.redmine.url": "URL de Redmine", "settings.redmine.url.validation.required": "L'URL est requise", "settings.redmine.url.validation.valid-url": "Entrez une URL valide", - "settings.redmine.api-key": "Clé API Redmine", - "settings.redmine.api-key.validation.required": "La clé API est requise", - "settings.redmine.api-key.hint": "Où puis-je trouver ma clé API ? ici", + "settings.redmine.auth.method": "Authentication method", + "settings.redmine.auth.method.api-key": "API Key", + "settings.redmine.auth.method.oauth2": "OAuth2 (experimental)", + "settings.redmine.auth.api-key": "Clé API Redmine", + "settings.redmine.auth.api-key.validation.required": "La clé API est requise", + "settings.redmine.auth.api-key.hint": "Où puis-je trouver ma clé API ? ici", + "settings.redmine.auth.oauth2.setup": "OAuth2 setup (since Redmine 6.1.0)", + "settings.redmine.auth.oauth2.setup.description": "You need to register an OAuth2 application in your Redmine instance. This requires administrator permissions.", + "settings.redmine.auth.oauth2.setup.application-name": "Name", + "settings.redmine.auth.oauth2.setup.redirect-uri": "Redirect URI", + "settings.redmine.auth.oauth2.client-id": "Client ID", + "settings.redmine.auth.oauth2.client-id.validation.required": "Client ID is required", + "settings.redmine.auth.oauth2.client-secret": "Client Secret", + "settings.redmine.auth.oauth2.client-secret.validation.required": "Client Secret is required", + "settings.redmine.auth.oauth2.scopes": "Scopes/Permissions", + "settings.redmine.auth.oauth2.scopes.description": "Select permissions depending on the features you want to use", + "settings.redmine.auth.oauth2.scopes.view_issues": "View issues", + "settings.redmine.auth.oauth2.scopes.add_issues": "Add issues", + "settings.redmine.auth.oauth2.scopes.edit_issues": "Edit issues", + "settings.redmine.auth.oauth2.scopes.edit_own_issues": "Edit own issues", + "settings.redmine.auth.oauth2.scopes.add_issue_notes": "Add issue notes", + "settings.redmine.auth.oauth2.scopes.set_notes_private": "Mark notes as private", + "settings.redmine.auth.oauth2.scopes.view_time_entries": "View spent time", + "settings.redmine.auth.oauth2.scopes.log_time": "Log spent time", + "settings.redmine.auth.oauth2.scopes.edit_own_time_entries": "Edit own time logs", + "settings.redmine.auth.oauth2.scopes.log_time_for_other_users": "Log spent time for other users", + "settings.redmine.auth.oauth2.authorize": "Authorize with OAuth2", + "settings.redmine.auth.oauth2.authorization-failed": "OAuth2 authorization failed: {error}", "settings.redmine.connecting": "Connexion...", "settings.redmine.connection-failed": "Échec de la connexion", "settings.redmine.connection-successful": "Connexion réussie !", @@ -192,6 +217,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 introuvable", diff --git a/src/lang/ru.json b/src/lang/ru.json index 5e637458..43ecbea8 100644 --- a/src/lang/ru.json +++ b/src/lang/ru.json @@ -138,9 +138,34 @@ "settings.redmine.url": "Redmine URL", "settings.redmine.url.validation.required": "Укажите ссылку на главную страницу Redmine", "settings.redmine.url.validation.valid-url": "Указанный URL содержит ошибки", - "settings.redmine.api-key": "Redmine API-ключ", - "settings.redmine.api-key.validation.required": "Укажите API-ключ", - "settings.redmine.api-key.hint": "Где я могу найти мой API-ключ? здесь", + "settings.redmine.auth.method": "Authentication method", + "settings.redmine.auth.method.api-key": "API Key", + "settings.redmine.auth.method.oauth2": "OAuth2 (experimental)", + "settings.redmine.auth.api-key": "Redmine API-ключ", + "settings.redmine.auth.api-key.validation.required": "Укажите API-ключ", + "settings.redmine.auth.api-key.hint": "Где я могу найти мой API-ключ? здесь", + "settings.redmine.auth.oauth2.setup": "OAuth2 setup (since Redmine 6.1.0)", + "settings.redmine.auth.oauth2.setup.description": "You need to register an OAuth2 application in your Redmine instance. This requires administrator permissions.", + "settings.redmine.auth.oauth2.setup.application-name": "Name", + "settings.redmine.auth.oauth2.setup.redirect-uri": "Redirect URI", + "settings.redmine.auth.oauth2.client-id": "Client ID", + "settings.redmine.auth.oauth2.client-id.validation.required": "Client ID is required", + "settings.redmine.auth.oauth2.client-secret": "Client Secret", + "settings.redmine.auth.oauth2.client-secret.validation.required": "Client Secret is required", + "settings.redmine.auth.oauth2.scopes": "Scopes/Permissions", + "settings.redmine.auth.oauth2.scopes.description": "Select permissions depending on the features you want to use", + "settings.redmine.auth.oauth2.scopes.view_issues": "View issues", + "settings.redmine.auth.oauth2.scopes.add_issues": "Add issues", + "settings.redmine.auth.oauth2.scopes.edit_issues": "Edit issues", + "settings.redmine.auth.oauth2.scopes.edit_own_issues": "Edit own issues", + "settings.redmine.auth.oauth2.scopes.add_issue_notes": "Add issue notes", + "settings.redmine.auth.oauth2.scopes.set_notes_private": "Mark notes as private", + "settings.redmine.auth.oauth2.scopes.view_time_entries": "View spent time", + "settings.redmine.auth.oauth2.scopes.log_time": "Log spent time", + "settings.redmine.auth.oauth2.scopes.edit_own_time_entries": "Edit own time logs", + "settings.redmine.auth.oauth2.scopes.log_time_for_other_users": "Log spent time for other users", + "settings.redmine.auth.oauth2.authorize": "Authorize with OAuth2", + "settings.redmine.auth.oauth2.authorization-failed": "OAuth2 authorization failed: {error}", "settings.redmine.connecting": "Подключение...", "settings.redmine.connection-failed": "Ошибка подключения", "settings.redmine.connection-successful": "Подключение успешно!", @@ -192,6 +217,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": "Страница не найдена", diff --git a/src/provider/PermissionsProvider.tsx b/src/provider/PermissionsProvider.tsx index 1e541783..35aec0d0 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.redmine.auth.method === "apiKey") || + (getProjectRoles(projectId).some((r) => r.permissions.includes(permission)) && (settings.redmine.auth.method === "apiKey" || hasOAuth2Scope(permission))) || + false; return ( {children} 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..bcfe1fe1 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..6ec1eac9 100644 --- a/src/provider/SettingsProvider.tsx +++ b/src/provider/SettingsProvider.tsx @@ -13,19 +13,61 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType>; const defaultSettings: Settings = { language: "browser", redmineURL: "", - redmineApiKey: "", + redmine: { + auth: { + method: "apiKey" as "apiKey" | "oauth2", + apiKey: "", + oauth2: { + clientId: "", + clientSecret: "", + scopes: { + view_issues: true, // Always enabled + add_issues: false, + edit_issues: false, + edit_own_issues: false, + add_issue_notes: true, + set_notes_private: false, + view_time_entries: true, // Always enabled + log_time: true, // Always enabled + edit_own_time_entries: true, + log_time_for_other_users: false, + }, + }, + }, + }, features: { autoPauseOnSwitch: true, roundToInterval: false, @@ -109,6 +172,15 @@ export const runSettingsMigration = async () => { settings.style.showIssuesPriority = undefined; } + if (settings.redmineApiKey) { + settings.redmine.auth = { + ...settings.redmine.auth, + method: "apiKey", + apiKey: settings.redmineApiKey, + }; + delete 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..39171a2b 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -1,11 +1,15 @@ /* 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 { TOAuth2Scope } from "@/api/redmine/types"; 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, FieldLegend, FieldSet } 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 { getErrorMessage } from "@/utils/error"; import { useStore as useFormStore } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -14,8 +18,10 @@ import { ArrowDownIcon, ArrowDownUpIcon, ArrowUpIcon, + AsteriskIcon, BugIcon, ChevronRightIcon, + CopyIcon, ExternalLinkIcon, GlobeIcon, Loader2Icon, @@ -26,8 +32,8 @@ import { UserIcon, Wand2Icon, } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useIntl } from "react-intl"; +import { useEffect, useEffectEvent, useState } from "react"; +import { MessageDescriptor, useIntl } from "react-intl"; import { toast } from "sonner"; import { browser } from "wxt/browser"; import { Form } from "../components/ui/form"; @@ -287,6 +293,19 @@ function PageComponent() { ); } +const OAUTH2_SCOPES = [ + { scope: "view_issues" as const, label: "settings.redmine.auth.oauth2.scopes.view_issues", required: true }, + { scope: "add_issues" as const, label: "settings.redmine.auth.oauth2.scopes.add_issues", required: false }, + { scope: "edit_issues" as const, label: "settings.redmine.auth.oauth2.scopes.edit_issues", required: false }, + { scope: "edit_own_issues" as const, label: "settings.redmine.auth.oauth2.scopes.edit_own_issues", required: false }, + { scope: "add_issue_notes" as const, label: "settings.redmine.auth.oauth2.scopes.add_issue_notes", required: false }, + { scope: "set_notes_private" as const, label: "settings.redmine.auth.oauth2.scopes.set_notes_private", required: false }, + { scope: "view_time_entries" as const, label: "settings.redmine.auth.oauth2.scopes.view_time_entries", required: true }, + { scope: "log_time" as const, label: "settings.redmine.auth.oauth2.scopes.log_time", required: true }, + { scope: "edit_own_time_entries" as const, label: "settings.redmine.auth.oauth2.scopes.edit_own_time_entries", required: false }, + { scope: "log_time_for_other_users" as const, label: "settings.redmine.auth.oauth2.scopes.log_time_for_other_users", required: false }, +] satisfies { scope: TOAuth2Scope; label: NonNullable; required: boolean }[]; + const RedmineServerSection = withForm({ defaultValues: {} as Settings, validators: { @@ -296,14 +315,14 @@ const RedmineServerSection = withForm({ const { formatMessage } = useIntl(); const [editRedmineInstance, setEditRedmineInstance] = useState(!form.state.values.redmineURL); - const [redmineApiClient, setRedmineApiClient] = useState(undefined); - const redmineConnection = useTestRedmineConnection(redmineApiClient); + const redmineConnection = useTestRedmineConnection(); const isSubmitted = useFormStore(form.store, (state) => state.isSubmitted); + const resetRedmineApiClient = useEffectEvent(() => redmineConnection.setRedmineApiClient(undefined)); useEffect(() => { if (isSubmitted) { setEditRedmineInstance(false); - setRedmineApiClient(undefined); + resetRedmineApiClient(); } }, [isSubmitted]); @@ -318,16 +337,18 @@ const RedmineServerSection = withForm({ {editRedmineInstance ? ( ({ - isValid: !!state.values.redmineURL && !!state.values.redmineApiKey && !state.errorMap.onChange?.redmineURL, + isUrlValid: !state.errorMap.onChange?.redmineURL, + isAuthValid: !Object.keys(state.errorMap.onChange || {}).some((key) => key.startsWith("redmine.auth")), })} - children={({ isValid }) => ( + children={({ isUrlValid, isAuthValid }) => ( + )} + + )} ) : redmineConnection.data ? ( <> @@ -413,9 +561,7 @@ const RedmineServerSection = withForm({ {formatMessage( - { - id: "settings.redmine.hello-user", - }, + { id: "settings.redmine.hello-user" }, { firstName: redmineConnection.data.firstname, lastName: redmineConnection.data.lastname, 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) => {