diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index fc2c714d..0de176a9 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -10,12 +10,12 @@ jobs: outputs: has_changes: ${{ steps.check_for_changes.outputs.has_changes }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 3 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: '22.14.0' + node-version: 22 - name: Install turbo run: npm install -g turbo@2.4.4 && npm install -g turbo-ignore - name: Check for changes @@ -29,6 +29,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 3 + - uses: actions/setup-node@v4 + with: + node-version: '22' - name: Install turbo run: npm install -g turbo@2.4.4 - name: Login to Docker diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index d2d4d40a..f392a52b 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -42,6 +42,7 @@ jobs: echo NEXT_PUBLIC_API_URL="https://api.buildtheearth.net/api/v1" >> apps/dashboard/.env echo NEXT_PUBLIC_SMYLER_API_URL="https://smybteapi.buildtheearth.net" >> apps/dashboard/.env echo NEXT_PUBLIC_FRONTEND_URL="https://buildtheearth.net" >> apps/dashboard/.env + # echo DATABASE_URL="${{ secrets.DATABASE_URL }}" >> apps/dashboard/.env - name: Build the Docker image run: docker build . --file apps/dashboard/Dockerfile --tag ghcr.io/buildtheearth/dashboard-website:$(git rev-parse --short HEAD) --tag ghcr.io/buildtheearth/dashboard-website:latest - name: Docker push tag diff --git a/.vscode/settings.json b/.vscode/settings.json index eb5d4a82..1f8c20b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,14 @@ "frontend/seo", "api/applications", "api/claims", - "frontend/legal" + "frontend/legal", + "frontend/gallery", + "dash/editor", + "db", + "dash/responsive", + "dash/team" + ], + "githubPullRequests.ignoredPullRequestBranches": [ + "main" ] } diff --git a/README.md b/README.md index eaae4a9e..f7c70e3b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- + # @BuildTheEarth/web @@ -29,23 +29,24 @@ This repository contains the following apps and other shared packages: ## Table of Contents -+ [@BuildTheEarth/web](#buildtheearthweb) - + [Apps and Packages](#apps-and-packages) - + [Table of Contents](#table-of-contents) - + [Getting Started](#getting-started) - + [Bugs and Features](#bugs-and-features) - + [Building](#building) - + [Building all applications](#building-all-applications) - + [Building a single application](#building-a-single-application) - + [CI/CD](#cicd) +- [@BuildTheEarth/web](#buildtheearthweb) + - [Apps and Packages](#apps-and-packages) + - [Table of Contents](#table-of-contents) + - [Getting Started](#getting-started) + - [Bugs and Features](#bugs-and-features) + - [Building](#building) + - [Building all applications](#building-all-applications) + - [Building a single application](#building-a-single-application) + - [CI/CD](#cicd) ## Getting Started First, clone this repository: ```bash -git clone https://github.com/BuildTheEarth/web.git +git clone https://github.com/BuildTheEarth/web.git ``` + It is recommended to install [Turborepo](https://turbo.build/repo/docs) globally: ```bash @@ -59,6 +60,7 @@ yarn install # and optionally yarn db:generate ``` + Now, copy the example `.env` file and change all its options: ```bash @@ -70,9 +72,11 @@ Then you can start the development server with: ```bash yarn dev ``` + This will also start the Prisma Studio. ## Bugs and Features + We use [GitHub Issues](https://github.com/BuildTheEarth/website-frontend/issues) to manage all bugs and features. You can submit a new bug or feature request [here](https://github.com/BuildTheEarth/website-frontend/issues/new). An overview of the state of bugs and features can be found [here](https://github.com/orgs/BuildTheEarth/projects/11). ## Building @@ -92,6 +96,7 @@ yarn clean ```bash yarn build ``` + Due to the use of Turborepo, this command will only build applications that have changed since the last build! ### Building a single application @@ -112,4 +117,4 @@ turbo build --filter=[...] ## CI/CD -This monorepo uses [GitHub Actions](https://github.com/BuildTheEarth/web/actions) to create deployable Docker images, which are pushed to the [GitHub Container Registry](https://github.com/orgs/BuildTheEarth/packages). \ No newline at end of file +This monorepo uses [GitHub Actions](https://github.com/BuildTheEarth/web/actions) to create deployable Docker images, which are pushed to the [GitHub Container Registry](https://github.com/orgs/BuildTheEarth/packages). diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index ce4441ae..0ac137de 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,4 +1,4 @@ -FROM node:21-alpine AS base +FROM node:22-alpine AS base FROM base AS builder RUN apk update @@ -18,6 +18,9 @@ WORKDIR /app # Install dependencies COPY --from=builder /app/out/json/ . +# Enable corepack to use the correct Yarn version +RUN corepack enable +RUN corepack prepare yarn@4.9.1 --activate RUN yarn install # Build the project diff --git a/apps/api/src/controllers/BuildTeamController.ts b/apps/api/src/controllers/BuildTeamController.ts index 37f3493d..93d10d3d 100644 --- a/apps/api/src/controllers/BuildTeamController.ts +++ b/apps/api/src/controllers/BuildTeamController.ts @@ -15,6 +15,19 @@ class BuildTeamController { this.core = core; } + private async getKeycloakMemberSafe(ssoId: string) { + try { + return await this.core.getKeycloakAdmin().getKeycloakAdminClient().users.findOne({ + id: ssoId, + }); + } catch (error) { + this.core + .getLogger() + .warn(`Failed to fetch Keycloak user ${ssoId}: ${error instanceof Error ? error.message : error}`); + return null; + } + } + /** * Get Information about multiple Buildteams, may paginate */ @@ -453,9 +466,7 @@ class BuildTeamController { // Get Keycloak information about all members present and mutate the object const kcMembers = await Promise.all( members.map(async (member) => { - const kcMember = await this.core.getKeycloakAdmin().getKeycloakAdminClient().users.findOne({ - id: member.ssoId, - }); + const kcMember = await this.getKeycloakMemberSafe(member.ssoId); return { id: member.id, ssoId: member.ssoId, @@ -525,9 +536,7 @@ class BuildTeamController { // Mutate users with information from keycloak const kcMembers = await Promise.all( members.map(async (member) => { - const kcMember = await this.core.getKeycloakAdmin().getKeycloakAdminClient().users.findOne({ - id: member.ssoId, - }); + const kcMember = await this.getKeycloakMemberSafe(member.ssoId); return { id: member.id, ssoId: member.ssoId, diff --git a/apps/api/src/controllers/ClaimController.ts b/apps/api/src/controllers/ClaimController.ts index dbbb7071..8e82ca8e 100644 --- a/apps/api/src/controllers/ClaimController.ts +++ b/apps/api/src/controllers/ClaimController.ts @@ -11,11 +11,49 @@ import { userHasPermissions } from '../web/routes/utils/CheckUserPermissionMiddl class ClaimController { private core: Core; + private static readonly OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; + private static readonly OVERPASS_MIN_INTERVAL_MS = 1200; + private static nextOverpassRequestAt = 0; constructor(core: Core) { this.core = core; } + private wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private readHeader(headers: Record | undefined, key: string): string | undefined { + if (!headers) return undefined; + const value = headers[key.toLowerCase()]; + if (Array.isArray(value)) return value[0]?.toString(); + if (value === undefined || value === null) return undefined; + return value.toString(); + } + + private parseRetryAfterMs(headers: Record | undefined): number | undefined { + const retryAfter = this.readHeader(headers, 'retry-after'); + if (!retryAfter) return undefined; + + const seconds = parseInt(retryAfter, 10); + if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1000); + + const retryAt = Date.parse(retryAfter); + if (!Number.isNaN(retryAt)) return Math.max(0, retryAt - Date.now()); + + return undefined; + } + + private previewOverpassBody(data: unknown): string { + if (data === undefined || data === null) return 'empty'; + if (typeof data === 'string') return data.replace(/\s+/g, ' ').trim().slice(0, 500); + try { + return JSON.stringify(data).slice(0, 500); + } catch { + return '[unserializable-response-body]'; + } + } + public async getClaims(req: Request, res: Response) { const errors = validationResult(req); if (!errors.isEmpty()) { @@ -422,9 +460,20 @@ class ClaimController { const area = req.body.area; const center = area && turf.center(toPolygon(area)).geometry.coordinates.join(', '); - const buildingCount = area && (await this.updateClaimBuildingCount({ area }, false)); + let buildingCount; - if (buildingCount == undefined || buildingCount == null || typeof buildingCount != 'number') { + try { + buildingCount = area && (await this.updateClaimBuildingCount({ area }, false)); + if (buildingCount == undefined || buildingCount == null || typeof buildingCount != 'number') { + this.core + .getLogger() + .error(`Failed to get building count for new claim (bt: ${buildteam.id}, name: ${req.body.name})`); + return ERROR_GENERIC(req, res, 500, 'Could not update building count'); + } + } catch (e) { + this.core + .getLogger() + .error(`Error while getting building count for new claim (bt: ${buildteam.id}, name: ${req.body.name}): ${e}`); return ERROR_GENERIC(req, res, 500, 'Could not update building count'); } @@ -549,6 +598,7 @@ class ClaimController { update?: boolean, ): Promise { const polygon = toOverpassPolygon(claim.area); + const claimId = claim.id || 'unknown'; const overpassQuery = `[out:json][timeout:25]; ( @@ -558,34 +608,135 @@ class ClaimController { ); out count;`; - try { - const { data } = await axios.post( - `https://overpass.private.coffee/api/interpreter?`, - `data=${overpassQuery.replace('\n', '')}`, - ); + const maxAttempts = 4; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const now = Date.now(); + const waitForSlot = Math.max(0, ClaimController.nextOverpassRequestAt - now); + if (waitForSlot > 0) { + this.core + .getLogger() + .debug(`Overpass pacing wait ${waitForSlot}ms (claim: ${claimId}; attempt: ${attempt}/${maxAttempts})`); + await this.wait(waitForSlot); + } + + ClaimController.nextOverpassRequestAt = Date.now() + ClaimController.OVERPASS_MIN_INTERVAL_MS; + + const params = new URLSearchParams(); + params.append('data', overpassQuery); - if (!data?.elements || data?.elements.length <= 0) { this.core .getLogger() - .error( - `Claim did not contain any elements, setting building count to 0 (https://overpass.private.coffee/api/interpreter; claim: ${claim.id})`, + .debug( + `Overpass request start (claim: ${claimId}; attempt: ${attempt}/${maxAttempts}; areaPoints: ${claim.area.length})`, ); - return 0; - } - if (update) { - const updatedClaim = await this.core.getPrisma().claim.update({ - where: { id: claim.id }, - data: { buildings: parseInt(data?.elements[0]?.tags?.total) || 0 }, + const response = await axios.post(ClaimController.OVERPASS_URL, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'BuildTheEarth/1.0 (contact: development@buildtheearth.net)', + Referer: 'https://buildtheearth.net', + Origin: 'https://buildtheearth.net', + }, + timeout: 45000, + validateStatus: () => true, }); - return updatedClaim; - } else { - return parseInt(data?.elements[0]?.tags?.total) || 0; + + const responseHeaders = (response.headers || {}) as Record; + const retryAfterHeader = this.readHeader(responseHeaders, 'retry-after'); + const overpassRateLimit = this.readHeader(responseHeaders, 'x-ratelimit-limit'); + const overpassRateRemaining = this.readHeader(responseHeaders, 'x-ratelimit-remaining'); + const overpassRateReset = this.readHeader(responseHeaders, 'x-ratelimit-reset'); + + if (response.status >= 200 && response.status < 300) { + const data = response.data; + + this.core + .getLogger() + .debug( + `Overpass request success (claim: ${claimId}; status: ${response.status}; remaining: ${overpassRateRemaining || 'n/a'}; reset: ${overpassRateReset || 'n/a'})`, + ); + + if (!data?.elements || data?.elements.length <= 0) { + this.core + .getLogger() + .error( + `Claim did not contain any elements, setting building count to 0 (${ClaimController.OVERPASS_URL}; claim: ${claimId})`, + ); + return 0; + } + + if (update) { + const updatedClaim = await this.core.getPrisma().claim.update({ + where: { id: claim.id }, + data: { buildings: parseInt(data?.elements[0]?.tags?.total) || 0 }, + }); + return updatedClaim; + } + + return parseInt(data?.elements[0]?.tags?.total) || 0; + } + + const bodyPreview = this.previewOverpassBody(response.data); + const computedBackoffMs = 1000 * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250); + const retryAfterMs = this.parseRetryAfterMs(responseHeaders) || computedBackoffMs; + + const isRetryable = + response.status === 429 || response.status === 502 || response.status === 503 || response.status === 504; + + const logMethod = isRetryable ? 'warn' : 'error'; + this.core + .getLogger() + [ + logMethod + ](`Overpass non-success response (claim: ${claimId}; attempt: ${attempt}/${maxAttempts}; status: ${response.status}; statusText: ${response.statusText || 'n/a'}; retryAfter: ${retryAfterHeader || 'n/a'}; limit: ${overpassRateLimit || 'n/a'}; remaining: ${overpassRateRemaining || 'n/a'}; reset: ${overpassRateReset || 'n/a'}; bodyPreview: ${bodyPreview})`); + + if (isRetryable && attempt < maxAttempts) { + ClaimController.nextOverpassRequestAt = Math.max( + ClaimController.nextOverpassRequestAt, + Date.now() + retryAfterMs, + ); + await this.wait(retryAfterMs); + continue; + } + + this.core + .getLogger() + .error( + `Overpass request failed with status ${response.status}. Setting building count to -1. (claim: ${claimId})`, + ); + return -1; + } catch (e: unknown) { + const isAxiosError = axios.isAxiosError(e); + const status = isAxiosError ? e.response?.status : undefined; + const statusText = isAxiosError ? e.response?.statusText : undefined; + const responseHeaders = (isAxiosError ? e.response?.headers : undefined) as Record | undefined; + const bodyPreview = isAxiosError ? this.previewOverpassBody(e.response?.data) : 'n/a'; + const retryAfterMs = this.parseRetryAfterMs(responseHeaders) || 1000 * Math.pow(2, attempt - 1); + const isRetryableError = + status === 429 || status === 502 || status === 503 || status === 504 || status === undefined; + + this.core + .getLogger() + [ + isRetryableError ? 'warn' : 'error' + ](`Overpass request error (claim: ${claimId}; attempt: ${attempt}/${maxAttempts}; status: ${status || 'n/a'}; statusText: ${statusText || 'n/a'}; retryAfter: ${this.readHeader(responseHeaders, 'retry-after') || 'n/a'}; message: ${isAxiosError ? e.message : e instanceof Error ? e.message : String(e)}; bodyPreview: ${bodyPreview})`); + + if (isRetryableError && attempt < maxAttempts) { + ClaimController.nextOverpassRequestAt = Math.max( + ClaimController.nextOverpassRequestAt, + Date.now() + retryAfterMs, + ); + await this.wait(retryAfterMs); + continue; + } + + return e as { message: string }; } - } catch (e) { - this.core.getLogger().error(e.message + ` (https://overpass.private.coffee/api/interpreter; claim: ${claim.id})`); - return e; } + + return { message: 'Overpass retries exhausted' }; } public async updateClaimOSMDetails( diff --git a/apps/api/src/controllers/TokenRouteController.ts b/apps/api/src/controllers/TokenRouteController.ts index 1e7972cd..a0a0a5fd 100644 --- a/apps/api/src/controllers/TokenRouteController.ts +++ b/apps/api/src/controllers/TokenRouteController.ts @@ -264,7 +264,7 @@ class TokenRouteContoller { }); if (!claim || !claim.id) { - ERROR_GENERIC(req, res, 404, 'Claim does not exist.'); + return ERROR_GENERIC(req, res, 404, 'Claim does not exist.'); } await this.core.getPrisma().claim.delete({ where: { id: claim.id } }); diff --git a/apps/api/src/util/KeycloakAdmin.ts b/apps/api/src/util/KeycloakAdmin.ts index 968d98ac..089a9703 100644 --- a/apps/api/src/util/KeycloakAdmin.ts +++ b/apps/api/src/util/KeycloakAdmin.ts @@ -4,6 +4,8 @@ import Core from '../Core.js'; class KeycloakAdmin { private kcAdminClient: KcAdminClient; private core: Core; + private readonly maxRetries = 3; + private readonly baseRetryDelayMs = 250; constructor(core: Core) { this.core = core; @@ -11,6 +13,8 @@ class KeycloakAdmin { baseUrl: process.env.KEYCLOAK_URL, realmName: process.env.KEYCLOAK_REALM, }); + + this.wrapUsersApiWithRetries(); } public getKeycloakAdminClient() { @@ -18,12 +22,101 @@ class KeycloakAdmin { } public async authKcClient() { - return await this.kcAdminClient.auth({ - grantType: 'client_credentials', - clientId: process.env.KEYCLOAK_CLIENTID, - clientSecret: process.env.KEYCLOAK_CLIENTSECRET, + return await this.withNetworkRetry('auth.client_credentials', async () => { + return await this.kcAdminClient.auth({ + grantType: 'client_credentials', + clientId: process.env.KEYCLOAK_CLIENTID, + clientSecret: process.env.KEYCLOAK_CLIENTSECRET, + }); }); } + + private wrapUsersApiWithRetries() { + const usersApi = this.kcAdminClient.users as any; + + const originalFindOne = usersApi.findOne?.bind(usersApi); + if (originalFindOne) { + usersApi.findOne = async (params: any) => { + return await this.withNetworkRetry('users.findOne', async () => originalFindOne(params)); + }; + } + + const originalUpdate = usersApi.update?.bind(usersApi); + if (originalUpdate) { + usersApi.update = async (params: any, payload: any) => { + return await this.withNetworkRetry('users.update', async () => originalUpdate(params, payload)); + }; + } + + const originalListSessions = usersApi.listSessions?.bind(usersApi); + if (originalListSessions) { + usersApi.listSessions = async (params: any) => { + return await this.withNetworkRetry('users.listSessions', async () => originalListSessions(params)); + }; + } + } + + private async withNetworkRetry(operation: string, fn: () => Promise): Promise { + let attempt = 0; + while (true) { + try { + return await fn(); + } catch (error) { + attempt++; + if (!this.isRetryableNetworkError(error) || attempt >= this.maxRetries) { + throw error; + } + + const delay = this.baseRetryDelayMs * Math.pow(2, attempt - 1); + this.core + .getLogger() + .warn( + `Keycloak ${operation} failed (attempt ${attempt}/${this.maxRetries}) due to network issue. Retrying in ${delay}ms.`, + ); + await this.sleep(delay); + } + } + } + + private isRetryableNetworkError(error: unknown): boolean { + const candidates = this.collectErrorStrings(error); + return candidates.some((value) => { + const token = value.toUpperCase(); + return ( + token.includes('ENOTFOUND') || + token.includes('EAI_AGAIN') || + token.includes('ETIMEDOUT') || + token.includes('ECONNRESET') || + token.includes('ECONNREFUSED') || + token.includes('FETCH FAILED') + ); + }); + } + + private collectErrorStrings(error: unknown): string[] { + const values: string[] = []; + let current: any = error; + let guard = 0; + while (current && guard < 5) { + if (typeof current === 'string') { + values.push(current); + } + if (current?.code) { + values.push(String(current.code)); + } + if (current?.message) { + values.push(String(current.message)); + } + current = current?.cause; + guard++; + } + + return values; + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } } export default KeycloakAdmin; diff --git a/apps/api/src/util/package.ts b/apps/api/src/util/package.ts index ba5ca96a..82dad612 100644 --- a/apps/api/src/util/package.ts +++ b/apps/api/src/util/package.ts @@ -1 +1,2 @@ -export const LIB_VERSION = "1.1.0";export const LIB_LICENSE = undefined; +export const LIB_VERSION = '1.1.0'; +export const LIB_LICENSE = undefined; diff --git a/apps/api/src/web/routes/utils/Executor.ts b/apps/api/src/web/routes/utils/Executor.ts index 04830388..2b8ef69f 100644 --- a/apps/api/src/web/routes/utils/Executor.ts +++ b/apps/api/src/web/routes/utils/Executor.ts @@ -1,3 +1,3 @@ import { Request, Response } from 'express'; -export type Executor = (request: Request, response: Response) => void; +export type Executor = (request: Request, response: Response) => void | Promise; diff --git a/apps/api/src/web/routes/utils/Router.ts b/apps/api/src/web/routes/utils/Router.ts index 424040ed..ffb748f3 100644 --- a/apps/api/src/web/routes/utils/Router.ts +++ b/apps/api/src/web/routes/utils/Router.ts @@ -15,21 +15,41 @@ export default class Router { } public addRoute(requestMethod: RequestMethods, endpoint: String, executor: Executor, ...middlewares: any) { - this.web - .getCore() - .getLogger() - .debug(`Registering endpoint "${requestMethod.toString()} api/${this.version}${endpoint}"`); - this.web.getApp().all(`/api/${this.version}${endpoint}`, middlewares, (rq: Request, rs: Response, next: any) => { - if (rq.method === requestMethod.valueOf()) { - try { - executor(rq, rs); - } catch (e) { - ERROR_GENERIC(rq, rs, 500, 'Internal Server Error. Please try again and report this bug.'); - } + const fullEndpoint = `/api/${this.version}${endpoint}`; + const app = this.web.getApp(); - return; + this.web.getCore().getLogger().debug(`Registering endpoint "${requestMethod.toString()} ${fullEndpoint}"`); + + const methodHandler = async (rq: Request, rs: Response) => { + try { + await executor(rq, rs); + } catch (e) { + this.web + .getCore() + .getLogger() + .error(`Unhandled route error on ${requestMethod.toString()} ${fullEndpoint}: ${e}`); + ERROR_GENERIC(rq, rs, 500, 'Internal Server Error. Please try again and report this bug.'); } - next(); - }); + }; + + switch (requestMethod) { + case RequestMethods.GET: + app.get(fullEndpoint, ...middlewares, methodHandler); + break; + case RequestMethods.POST: + app.post(fullEndpoint, ...middlewares, methodHandler); + break; + case RequestMethods.PUT: + app.put(fullEndpoint, ...middlewares, methodHandler); + break; + case RequestMethods.DELETE: + app.delete(fullEndpoint, ...middlewares, methodHandler); + break; + case RequestMethods.HEAD: + app.head(fullEndpoint, ...middlewares, methodHandler); + break; + default: + app.all(fullEndpoint, ...middlewares, methodHandler); + } } } diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 19db7d6c..218ee5cc 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -1,26 +1,44 @@ # # Keycloak # -NEXT_PUBLIC_KEYCLOAK_URL="https://yourkeycloak.net/realms/yourrealm" -NEXT_PUBLIC_KEYCLOAK_ID="yourclient" -KEYCLOAK_SECRET="topsecret" +NEXT_PUBLIC_KEYCLOAK_URL="https://.../realms/..." +NEXT_PUBLIC_KEYCLOAK_ID="..." +KEYCLOAK_SECRET="..." +KEYCLOAK_ADMIN_CLIENT_ID="..." +KEYCLOAK_ADMIN_CLIENT_SECRET="..." # -# NextAuth +# Auth # -NEXTAUTH_URL="http://localhost:3000" -NEXTAUTH_SECRET="secondtopsecret" +NEXTAUTH_URL="http://localhost:3001" +NEXTAUTH_SECRET="..." +INTERNAL_API_KEY="..." # -# BuildTheEarth +# APIs # -NEXT_PUBLIC_API_URL="https://api.yourserver.net/api/v1" -NEXT_PUBLIC_SMYLER_API_URL="https://smybteapi.yourserver.net" -NEXT_PUBLIC_FRONTEND_URL="https://yourserver.net" -FRONTEND_KEY="thirdtopsecret" +NEXT_PUBLIC_API_URL="https://.../api/v1" +NEXT_PUBLIC_SMYLER_API_URL="https://..." +# +# Main Website # -# Other Confirguration +NEXT_PUBLIC_FRONTEND_URL="https://..." +FRONTEND_KEY="..." + +# +# Mapbox +# +NEXT_PUBLIC_MAPBOX_TOKEN="..." + +# +# Database +# +DATABASE_URL="postgresql://user:password@server:5432/database?pool_timeout=0" + +# +# DISCORD # REPORTS_WEBHOOK="https://discord.com/api/webhooks/..." -NEXT_PUBLIC_MAPBOX_TOKEN="fourthtopsecret" \ No newline at end of file +DISCORD_BOT_URL="https://..." +DISCORD_BOT_SECRET="..." \ No newline at end of file diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index dec2aedd..373aded7 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -1,8 +1,8 @@ -FROM node:21-alpine AS base +FROM node:22-alpine AS base +RUN corepack enable FROM base AS builder -RUN apk update -RUN apk add --no-cache libc6-compat +RUN apk update && apk add --no-cache libc6-compat openssl WORKDIR /app # Run turbo (will prune the lockfile to only include target dependencies) @@ -12,12 +12,14 @@ RUN turbo prune dashboard --docker # Add lockfile and package.json's of isolated subworkspace FROM base AS installer -RUN apk update -RUN apk add --no-cache libc6-compat +RUN apk update && apk add --no-cache libc6-compat openssl +# This is only for prisma v5 because it only looks in /lib for openssl libaries +RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 WORKDIR /app # First install the dependencies (as they change less often) COPY --from=builder /app/out/json/ . +# RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 RUN yarn install # Build the project @@ -25,7 +27,6 @@ COPY --from=builder /app/out/full/ . COPY --from=builder /app/apps/dashboard/.env ./apps/dashboard/.env ENV NEXT_TELEMETRY_DISABLED 1 - RUN yarn turbo run build --filter=dashboard... FROM base AS runner @@ -35,8 +36,8 @@ ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED 1 # Create a runner user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs USER nextjs # Reduce image size diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 64a39b46..eb53998a 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -22,16 +22,22 @@ See the global Readme file. # Envoriment Variables -| Variable | Example Value | Description | -| -------------------------- | ----------------------------------------- | ----------------------------------------------------------------- | -| NEXT_PUBLIC_KEYCLOAK_URL | https://yourkeycloak.net/realms/yourrealm | The Keycloak SSO URL, including the realm | -| NEXT_PUBLIC_KEYCLOAK_ID | yourclient | A client ID for your Keycloak Installation | -| KEYCLOAK_SECRET | topsecret | The client secret of your client | -| NEXTAUTH_URL | http://localhost:3000 | The URL NextAuth should use for redirections back to your website | -| NEXTAUTH_SECRET | secondtopsecret | A secret used by NextAuth to encrypt session information | -| NEXT_PUBLIC_API_URL | https://api.yourserver.net/api/v1 | The URL of your deployed or local BuildTheEarth API | -| NEXT_PUBLIC_SMYLER_API_URL | https://smybteapi.yourserver.net | The URL of your deployed or local SmyBTE API | -| NEXT_PUBLIC_MAPBOX_TOKEN | fourthtopsecret | Your personal mapbox studio token | -| NEXT_PUBLIC_FRONTEND_URL | https://yourserver.net | The URL to your local or deployed BuildTheEarth Website | -| FRONTEND_KEY | thirdtopsecret | The Key used to Authenticate against the BuildTheEarth Website | -| REPORTS_WEBHOOK | https://discord.com/api/webhooks/... | A discord webhook to send reports to | +| Variable | Example Value | Description | +| ---------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ | +| NEXT_PUBLIC_KEYCLOAK_URL | https://yourkeycloak.net/realms/yourrealm | The Keycloak SSO URL, including the realm | +| NEXT_PUBLIC_KEYCLOAK_ID | yourclient | A client ID for your Keycloak Installation | +| KEYCLOAK_SECRET | topsecret | The client secret of your client | +| KEYCLOAK_ADMIN_CLIENT_ID | yourclient | A client ID for your Keycloak Installation (Admin users client) | +| KEYCLOAK_ADMIN_CLIENT_SECRET | topsecret | The client secret of your admin client | +| NEXTAUTH_URL | http://localhost:3000 | The URL NextAuth should use for redirections back to your website | +| NEXTAUTH_SECRET | secondtopsecret | A secret used by NextAuth to encrypt session information | +| INTERNAL_API_KEY | internalsecret | A secret used by the website to send custom api requests to itself | +| NEXT_PUBLIC_API_URL | https://api.yourserver.net/api/v1 | The URL of your deployed or local BuildTheEarth API | +| NEXT_PUBLIC_SMYLER_API_URL | https://smybteapi.yourserver.net | The URL of your deployed or local SmyBTE API | +| NEXT_PUBLIC_FRONTEND_URL | https://yourserver.net | The URL to your local or deployed BuildTheEarth Website | +| FRONTEND_KEY | thirdtopsecret | The Key used to Authenticate against the BuildTheEarth Website | +| NEXT_PUBLIC_MAPBOX_TOKEN | fourthtopsecret | Your personal mapbox studio token | +| DATABASE_URL | postgresql://user:password@server:5432/database?pool_timeout=0 | Your Database connection string | +| REPORTS_WEBHOOK | https://discord.com/api/webhooks/... | A discord webhook to send reports to | +| DISCORD_BOT_URL | https://bot.yourserver.net/... | The URL to your local or delpolyed BuildTheEarth Main Bot | +| DISCORD_BOT_SECRET | fifthtopsecret | The secret key to your Main Bot instance | diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 20ce639b..8136ea61 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -19,7 +19,20 @@ const nextConfig: NextConfig = { poweredByHeader: false, outputFileTracingRoot: path.join(__dirname, '../../'), images: { - domains: ['cdn.buildtheearth.net'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'cdn.buildtheearth.net', + port: '', + pathname: '/uploads/**', + }, + { + protocol: 'https', + hostname: 'cdn.buildtheearth.net', + port: '', + pathname: '/static/**', + }, + ], }, }; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6eb83c3c..89801cfa 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -14,18 +14,19 @@ }, "dependencies": { "@bte-germany/terraconvert": "^1.1.2", - "@mantine/charts": "^7.17.4", - "@mantine/code-highlight": "^7.17.4", - "@mantine/core": "^7.17.4", - "@mantine/dates": "^7.17.4", - "@mantine/form": "^7.17.4", - "@mantine/hooks": "^7.17.4", - "@mantine/modals": "^7.17.4", - "@mantine/notifications": "^7.17.4", - "@mantine/nprogress": "^7.17.4", - "@mantine/spotlight": "^7.17.4", - "@mantine/tiptap": "^7.17.4", - "@mapbox/mapbox-gl-draw": "^1.4.3", + "@keycloak/keycloak-admin-client": "^26.2.0", + "@mantine/charts": "^8.2.3", + "@mantine/code-highlight": "^8.2.3", + "@mantine/core": "^8.2.3", + "@mantine/dates": "^8.2.3", + "@mantine/form": "^8.2.3", + "@mantine/hooks": "^8.2.3", + "@mantine/modals": "^8.2.3", + "@mantine/notifications": "^8.2.3", + "@mantine/nprogress": "^8.2.3", + "@mantine/spotlight": "^8.2.3", + "@mantine/tiptap": "^8.2.3", + "@mapbox/mapbox-gl-draw": "^1.5.0", "@repo/db": "*", "@tabler/icons-react": "^3.9.0", "@tiptap/core": "^2.11.7", @@ -39,27 +40,33 @@ "@tiptap/pm": "^2.11.7", "@tiptap/react": "^2.11.7", "@tiptap/starter-kit": "^2.11.7", - "@types/mapbox__mapbox-gl-draw": "1.4.4", + "@turf/helpers": "^7.2.0", + "@turf/turf": "^7.2.0", + "axios": "^1.9.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "mantine-contextmenu": "^7.11.0", "mantine-datatable": "^7.12.4", "mapbox-gl": "2.13.0", - "mapbox-gl-draw-snap-mode": "^0.2.0", + "mapbox-gl-draw-snap-mode": "^0.4.0", "mapbox-gl-style-switcher": "^1.0.11", "moment": "^2.30.1", "moment-timezone": "^0.5.45", - "next": "15.3.0", + "next": "15.3.6", "next-auth": "^4.24.7", "next-transpile-modules": "^10.0.1", "react": "19.1.0", "react-dom": "19.1.0", "recharts": "^2.13.3", - "swr": "^2.2.5" + "swr": "^2.2.5", + "tiptap-markdown": "^0.9.0", + "zustand": "^5.0.4" }, "devDependencies": { "@repo/prettier-config": "*", "@repo/typescript-config": "*", + "@types/mapbox-gl": "^3.4.1", + "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/node": "^20", "@types/react": "19.1.1", "@types/react-dom": "19.1.2", diff --git a/apps/dashboard/src/actions/buildTeams.ts b/apps/dashboard/src/actions/buildTeams.ts index ad893109..6846fbad 100644 --- a/apps/dashboard/src/actions/buildTeams.ts +++ b/apps/dashboard/src/actions/buildTeams.ts @@ -1,8 +1,52 @@ 'use server'; import { revalidateWebsitePaths } from '@/util/data'; import prisma from '@/util/db'; +import { sendBotMessage } from '@/util/discordIntegration'; +import { sendBtWebhook, WebhookType } from '@/util/webhooks'; +import { Application, ApplicationQuestionType, ApplicationStatus, Prisma } from '@repo/db'; +import { randomBytes } from 'crypto'; import { revalidatePath } from 'next/cache'; +import build from 'next/dist/build'; +import { redirect } from 'next/navigation'; +const socialNameOptions = [ + 'twitter', + 'instagram', + 'facebook', + 'tiktok', + 'twitch', + 'youtube', + 'github', + 'website', +] as const; + +type SocialName = (typeof socialNameOptions)[number]; + +function normalizeSocialName(value: string): SocialName | null { + const normalized = value.trim().toLowerCase(); + return socialNameOptions.includes(normalized as SocialName) ? (normalized as SocialName) : null; +} + +function parseSocials(formData: FormData): Array<{ id?: string; name: string; url: string }> { + const socialsByIndex = new Map(); + + for (const [key, value] of Array.from(formData.entries())) { + const match = key.match(/^socials\[(\d+)\]\[(id|name|url)\]$/); + if (!match || typeof value !== 'string') continue; + + const index = Number(match[1]); + const field = match[2]; + const current = socialsByIndex.get(index) ?? { name: '', url: '' }; + + current[field as 'id' | 'name' | 'url'] = value; + socialsByIndex.set(index, current); + } + + return Array.from(socialsByIndex.entries()) + .sort(([left], [right]) => left - right) + .map(([, social]) => social) + .filter((social) => social.id || social.name.trim() || social.url.trim()); +} export const adminTransferTeam = async ( prevState: any, { @@ -45,7 +89,7 @@ export const adminTransferTeam = async ( select: { id: true }, }); const transaction = await prisma.$transaction( - members.map((m) => + members.map((m: { id: any }) => prisma.user.update({ where: { id: m.id }, data: { joinedBuildTeams: { connect: { id: destinationId } } } }), ), ); @@ -178,3 +222,680 @@ export const adminChangeTeamOwner = async ( revalidatePath(`/am/users/${newOwnerId}`); return { status: 'success', team }; }; + +export const userEditTeamInfo = async (formData: FormData): Promise => { + const userId = formData.get('userId') as string; + const id = formData.get('id') as string; + + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + userId, + buildTeamId: id, + permissionId: 'team.settings.edit', + }, + }); + + if (!userHasPermission) { + throw Error('User does not have permission to edit this information'); + } + + console.log(formData.keys()); + + const name = formData.get('name') as string; + const color = formData.get('color') as string; + const icon = formData.get('icon') as string; + const backgroundImage = formData.get('backgroundImage') as string; + const location = formData.get('location') as string; + const about = formData.get('about') as string; + const ip = formData.get('ip') as string; + const version = formData.get('version') as string; + const invite = formData.get('invite') as string; + const allowApplications = formData.get('allowApplications') === 'on'; + const allowBuilderClaim = formData.get('allowBuilderClaim') === 'on'; + const allowTrial = formData.get('allowTrial') === 'on'; + const acceptionMessage = formData.get('acceptionMessage') as string; + const rejectionMessage = formData.get('rejectionMessage') as string; + const trialMessage = formData.get('trialMessage') as string; + const webhook = formData.get('webhook') as string; + + const updatedTeam = await prisma.buildTeam.update({ + where: { id }, + data: { + name: name ?? undefined, + color: color ?? undefined, + icon: icon ?? undefined, + backgroundImage: backgroundImage ?? undefined, + location: location ?? undefined, + about: about ?? undefined, + ip: ip ?? undefined, + version: version ?? undefined, + invite: invite ?? undefined, + allowApplications: allowApplications ?? undefined, + allowBuilderClaim: allowBuilderClaim ?? undefined, + allowTrial: allowTrial ?? undefined, + acceptionMessage: acceptionMessage ?? undefined, + rejectionMessage: rejectionMessage ?? undefined, + trialMessage: trialMessage ?? undefined, + webhook: webhook ?? undefined, + }, + }); + + if (!updatedTeam) { + throw Error('Could not update Build Team'); + } + + revalidateWebsitePaths(['/teams', `/teams/${updatedTeam.slug}`]); + revalidatePath(`/team/${updatedTeam.slug}`); + redirect(`/team/${updatedTeam.slug}/edit?saved=1`); +}; + +export const userEditTeamSocials = async ( + _prevState: { status?: string; error?: string }, + formData: FormData, +): Promise<{ status: string; error?: string }> => { + const userId = formData.get('userId') as string; + const id = formData.get('id') as string; + + if (!userId || !id) { + return { status: 'error', error: 'Missing team context' }; + } + + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + userId, + buildTeamId: id, + permissionId: { + in: ['team.settings.edit', 'team.socials.edit'], + }, + }, + }); + + if (!userHasPermission) { + return { status: 'error', error: 'User does not have permission to edit these social links' }; + } + + const team = await prisma.buildTeam.findFirst({ + where: { id }, + select: { + id: true, + slug: true, + socials: { select: { id: true } }, + }, + }); + + if (!team) { + return { status: 'error', error: 'Could not find Build Team' }; + } + + const socials = parseSocials(formData); + + for (const social of socials) { + if (!social.name.trim() || !social.url.trim()) { + return { status: 'error', error: 'Every social link needs a platform and a URL' }; + } + + if (!normalizeSocialName(social.name)) { + return { status: 'error', error: `Invalid social platform: ${social.name}` }; + } + } + + const existingSocialIds = new Set(team.socials.map((social) => social.id)); + const submittedSocialIds = new Set(); + + await prisma.$transaction(async (tx) => { + for (const social of socials) { + const normalizedName = normalizeSocialName(social.name)!; + const payload = { + name: normalizedName, + icon: normalizedName, + url: social.url.trim(), + }; + + if (social.id) { + const currentSocial = await tx.social.findFirst({ + where: { id: social.id, buildTeamId: team.id }, + select: { id: true }, + }); + + if (!currentSocial) { + throw Error('One of the social links could not be found'); + } + + submittedSocialIds.add(currentSocial.id); + await tx.social.update({ + where: { id: currentSocial.id }, + data: payload, + }); + continue; + } + + const createdSocial = await tx.social.create({ + data: { + ...payload, + buildTeam: { connect: { id: team.id } }, + }, + select: { id: true }, + }); + + submittedSocialIds.add(createdSocial.id); + } + + const removedSocialIds = Array.from(existingSocialIds).filter((socialId) => !submittedSocialIds.has(socialId)); + + if (removedSocialIds.length > 0) { + await tx.social.deleteMany({ + where: { + buildTeamId: team.id, + id: { in: removedSocialIds }, + }, + }); + } + }); + + revalidateWebsitePaths(['/teams', `/teams/${team.slug}`]); + revalidatePath(`/team/${team.slug}`); + redirect(`/team/${team.slug}/edit?saved=1`); +}; + +export const ownerGenerateToken = async ({ userId, id }: { userId: string; id: string }): Promise => { + const userIsOwner = await prisma.buildTeam.findFirst({ + where: { + id, + creatorId: userId, + }, + include: { + creator: { select: { discordId: true } }, + }, + }); + if (!userIsOwner) { + throw Error('User is not the owner of this Build Team'); + } + const token = randomBytes(21).toString('hex'); + + await prisma.buildTeam.update({ + where: { id }, + data: { + token, + }, + }); + + sendBotMessage( + `## <:inprogress:1441532224473268234> ${userIsOwner.name} has a new API Token` + + `\n\nYou requested a new API Token for the BuildTheEarth API at https://api.buildtheearth.net. Below you will find this token. Please save it somewhere secure.` + + `\n\nToken: ||${token}|| \nSlug: \`${userIsOwner.slug}\``, + [userIsOwner.creator.discordId!], + ); +}; + +export const removeMember = async ({ + userId, + removeId, + buildTeamSlug, + reason, + notifyUser = true, +}: { + userId: string; + removeId: string; + reason?: string; + buildTeamSlug?: string; + notifyUser?: boolean; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'permission.remove', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'permission.remove', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to remove members from this Build Team'); + } + + const memberToRemove = await prisma.user.findFirst({ + where: { ssoId: removeId }, + }); + + const buildTeam = await prisma.buildTeam.update({ + where: { slug: buildTeamSlug }, + data: { + members: { + disconnect: { ssoId: removeId }, + }, + }, + }); + + if (notifyUser) { + sendBotMessage( + `## <:warn:1441532241628102686> You have been removed from ${buildTeam.name}` + + `\n\nThe Build Team \`${buildTeam.name}\` has removed you as a builder from their team. This means you are no longer part of their group and will not be able to create and manage claims for them. Additionally, you will not be able to apply to this Build Team again as long as your past application status is set to 'Accepted'.` + + (reason ? ` The team has provided the following reason for your removal: \n \n${reason}` : '') + + '\n\nIf you believe this was a mistake, please reach out to the Build Team directly for more information.', + [memberToRemove?.discordId!], + ); + } + + revalidatePath(`/am/users/${removeId}`); + revalidatePath(`/team/${buildTeam.slug}/members`); +}; + +export const addMember = async ({ + userId, + addId, + buildTeamSlug, + message, + notifyUser = true, +}: { + userId: string; + addId: string; + message?: string; + buildTeamSlug?: string; + notifyUser?: boolean; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'permission.add', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'permission.add', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to add members to this Build Team'); + } + + const memberToAdd = await prisma.user.findFirst({ + where: { OR: [{ ssoId: addId }, { id: addId }, { discordId: addId }, { username: addId }] }, + }); + + if (!memberToAdd) { + throw Error('User to add not found'); + } + + const buildTeam = await prisma.buildTeam.update({ + where: { slug: buildTeamSlug }, + data: { + members: { + connect: { ssoId: memberToAdd?.ssoId }, + }, + }, + }); + + if (notifyUser) { + sendBotMessage( + `## <:approved:1441532214562128034> You have been added to ${buildTeam.name}` + + `\n\nThe Build Team \`${buildTeam.name}\` has added you as a builder to their team. You did not have to fill out an application.` + + (message ? ` The team has provided the following message: \n \n${message}` : '') + + '\n\nIf you believe this was a mistake, please reach out to the Build Team directly for more information.', + [memberToAdd?.discordId!], + ); + // TODO: possibly add discord role if this is the first BT the user joins + } + + revalidatePath(`/am/users/${addId}`); + revalidatePath(`/team/${buildTeam.slug}/members`); +}; + +export const addApplicationResponseTemplate = async ({ + userId, + buildTeamSlug, + content, + name, +}: { + userId: string; + buildTeamSlug?: string; + content: string; + name: string; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'team.application.edit', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'team.application.edit', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to add a response template to this Build Team'); + } + + const template = await prisma.applicationResponseTemplate.create({ + data: { + name, + content, + buildteam: { connect: { slug: buildTeamSlug } }, + }, + }); + + revalidatePath(`/team/${buildTeamSlug}/applications`); + return template; +}; + +export const reviewApplication = async ({ + userId, + buildTeamSlug, + applicationId, + reason, + status, +}: { + userId: string; + buildTeamSlug?: string; + applicationId: string; + reason?: string; + status: ApplicationStatus; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'team.application.review', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'team.application.review', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to review an application to this Build Team'); + } + + const applicationOld = await prisma.application.findUnique({ + where: { id: applicationId, buildteam: { slug: buildTeamSlug } }, + }); + + if (!applicationOld) { + throw Error('Application not found'); + } + + const application = await prisma.application.update({ + where: { id: applicationOld.id, buildteam: { slug: buildTeamSlug } }, + data: { + status, + reviewer: { connect: { ssoId: userId } }, + reviewedAt: new Date(), + reason, + }, + include: { user: true, buildteam: true }, + }); + + if (status === ApplicationStatus.ACCEPTED || status === ApplicationStatus.TRIAL) { + await prisma.buildTeam.update({ + where: { slug: buildTeamSlug }, + data: { + members: { connect: { ssoId: application.user.ssoId } }, + }, + }); + + sendBotMessage( + parseApplicationMessage( + application.buildteam.acceptionMessage, + application, + application.user, + application.buildteam, + ), + [application.user.discordId!], + ); + + // TODO: possibly add discord role if this is the first BT the user joins and they got accepted to it + } + + if (status === ApplicationStatus.DECLINED) { + await prisma.buildTeam.update({ + where: { slug: buildTeamSlug }, + data: { + members: { disconnect: { ssoId: application.user.ssoId } }, + }, + }); + + sendBotMessage( + parseApplicationMessage( + application.buildteam.rejectionMessage, + application, + application.user, + application.buildteam, + ), + [application.user.discordId!], + ); + + //TODO: remove discord role if this is the only BT the user is in and they got declined from it + } + + // TODO: send webhook to staff dc + + if (application.buildteam.webhook) { + sendBtWebhook(application.buildteam.webhook, WebhookType.APPLICATION, application); + } + + revalidatePath(`/team/${buildTeamSlug}/applications`); + return application; +}; + +export const applyToBuildTeam = async ( + data: { userId: string; buildTeamSlug: string }, + formData: FormData, +): Promise => { + console.log(Object.fromEntries(formData)); + console.log(data); + + let buildteam = await prisma.buildTeam.findUnique({ + where: { slug: data.buildTeamSlug }, + select: { + instantAccept: true, + applicationQuestions: true, + id: true, + slug: true, + name: true, + acceptionMessage: true, + token: false, + allowApplications: true, + webhook: true, + }, + }); + + const user = await prisma.user.findUnique({ + where: { ssoId: data.userId }, + select: { id: true, discordId: true, ssoId: true, username: true }, + }); + + if (!buildteam) { + throw Error('Build Team not found'); + } + + if (!user) { + throw Error('User not found'); + } + + // TODO: check if user is on BTE.net discord + + const pastApplications = await prisma.application.findMany({ + where: { userId: user.id, buildteamId: buildteam.id }, + orderBy: { createdAt: 'desc' }, + }); + + if (pastApplications[0]?.status === ApplicationStatus.ACCEPTED) { + throw Error('You have already been accepted to this Build Team in the past, you cannot apply again'); + } + + if (!buildteam.allowApplications) { + throw Error('This Build Team is not accepting applications at the moment'); + } + + if (pastApplications.some((app) => app.status === ApplicationStatus.SEND)) { + throw Error( + 'You already have a pending application to this Build Team, please wait for it to be reviewed before applying again', + ); + } + + // Handle Instant-Accept + if (buildteam.instantAccept) { + const application = await prisma.application.create({ + data: { + buildteam: { connect: { id: buildteam.id } }, + user: { connect: user }, + status: ApplicationStatus.ACCEPTED, + createdAt: new Date(), + reviewedAt: new Date(), + trial: false, + }, + }); + + sendBotMessage(parseApplicationMessage(buildteam.acceptionMessage, application, user, buildteam), [ + user.discordId!, + ]); + + // TODO: possibly add discord role if this is the first BT the user joins + + revalidatePath(`/team/${buildteam.slug}/applications`); + return; + } + + const validatedAnswers = []; + + for (const question of buildteam.applicationQuestions) { + // Filter by correct questions + if (question.trial == false) { + if (formData.has(question.id)) { + let answer: any = formData.get(question.id); + const type = question.type; + + if (typeof answer != 'string') { + if (typeof answer == 'number') { + answer = answer.toString(); + } else { + try { + answer = JSON.stringify(answer); + } catch (e) {} + } + } + validatedAnswers.push({ id: question.id, answer: answer }); + + // If Type is minecraft, populate the minecraft name of the user + // TODO: verify account + if (type == ApplicationQuestionType.MINECRAFT) { + // TODO: update minecraft account name / check if account name is the same as verified name + } + } else if (question.required && question.sort >= 0) { + throw Error('Required Questions are missing'); + } + } + } + + // Save answers + if (validatedAnswers.length <= 0) { + throw Error('No valid answers provided'); + } + const application = await prisma.application.create({ + data: { + buildteam: { connect: { id: buildteam.id } }, + user: { connect: user }, + status: ApplicationStatus.SEND, + createdAt: new Date(), + trial: false, + ApplicationAnswer: { + createMany: { + data: validatedAnswers.map((a) => ({ + answer: a.answer, + questionId: a.id, + })), + }, + }, + }, + include: { + reviewer: true, + user: true, + }, + }); + + // Send Review Notification to Discord + const reviewers = await prisma.userPermission.findMany({ + where: { + permissionId: 'team.application.notify', + buildTeamId: buildteam.id, + }, + select: { user: { select: { id: true, discordId: true } } }, + }); + + await sendBotMessage( + `**${buildteam.name}** \\nNew Application from <@${user.discordId}> (${user.username}). Review it [here](${process.env.FRONTEND_URL}/team/${buildteam.slug}/applications/${application.id})`, + reviewers.map((r) => r.user.discordId!), + ); + + // Send Webhook to BuildTeam + if (buildteam.webhook) { + sendBtWebhook(buildteam.webhook, WebhookType.APPLICATION_SEND, application); + } + + revalidatePath(`/team/${buildteam.slug}/applications`); + return; +}; + +/** + * Replaces placeholders to actual data in discord messages to users + * @param message Message with placeholders + * @param application Application Information + * @param user User Information + * @param team Team Information + * @returns Replaced Message + */ +function parseApplicationMessage( + message: string, + application: Application, + user: { discordId: string | null }, + team: { slug: string; name: string }, +): string { + return message + .replace('{user}', `<@${user.discordId!}>`) + .replace('{team}', team.name) + .replace('{url}', process.env.FRONTEND_URL + `/teams/${team.slug}`) + .replace('{reason}', application.reason!) + .replace( + '{reviewedAt}', + new Date(application.reviewedAt!).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }), + ) + .replace( + '{createdAt}', + new Date(application.createdAt).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }), + ) + .replace('{id}', application.id.toString().split('-')[0]); +} diff --git a/apps/dashboard/src/actions/claimEditor.ts b/apps/dashboard/src/actions/claimEditor.ts new file mode 100644 index 00000000..c8c6ceb0 --- /dev/null +++ b/apps/dashboard/src/actions/claimEditor.ts @@ -0,0 +1,358 @@ +'use server'; + +import { constructClaimGeoJSONQuery } from '@/app/(sideNavbar)/api/data/claims.geojson/query'; +import turf, { toPolygon } from '@/util/coordinates'; +import prisma from '@/util/db'; +import { updateClaimBuildingCount, updateClaimOSMDetails } from '@/util/geojsonHelpers'; +import { Prisma } from '@repo/db'; +import { revalidatePath } from 'next/cache'; + +export const getPersonalClaims = async (userId: string) => { + const claims = await prisma.claim.findMany(constructClaimGeoJSONQuery({ user: userId, extended: true })); + return claims; +}; +export const getAllowedBuildTeams = async (userId: string) => { + const buildTeams = await prisma.buildTeam.findMany({ + where: { + members: { + some: { + ssoId: userId, + }, + }, + allowBuilderClaim: true, + }, + select: { + id: true, + }, + }); + return buildTeams.map((bt: { id: string }) => bt.id); +}; + +export const saveClaim = async (data: { id: string; userId: string; area?: string[] }): Promise => { + try { + const claim = await prisma.claim.findFirst({ + where: { id: data.id, owner: { ssoId: data.userId } }, + }); + + if (!claim) { + return Promise.reject('Claim not found or you do not have permission to edit this claim.'); + } + + let center = undefined; + if (data.area && data.area.length > 0) { + center = turf.center(toPolygon(data.area)).geometry.coordinates.join(', '); + } + + const buildingCount = data.area && (await updateClaimBuildingCount({ area: data.area })); + + if (typeof buildingCount !== 'number') { + if (buildingCount && typeof (buildingCount as { message?: string }).message === 'string') { + return Promise.reject((buildingCount as { message: string }).message); + } + return Promise.reject('Failed to update building count for claim.'); + } + + let osmDetails = undefined; + + if (center) { + osmDetails = await updateClaimOSMDetails({ id: data.id, name: claim.name, center }); + if (!osmDetails) { + return Promise.reject('Failed to update OSM details for claim.'); + } + } + + const claim2 = await prisma.claim.update({ + where: { id: data.id, owner: { ssoId: data.userId } }, + data: { + area: data.area, + center: center, + buildings: buildingCount, + ...osmDetails, + }, + }); + + revalidatePath('/editor'); + return; + } catch (e) { + let msg = 'Unknown error'; + if (e instanceof Prisma.PrismaClientKnownRequestError) { + msg = e.code; + if (e.code === 'P2025') { + msg = 'Claim not found or you do not have permission to edit this claim.'; + } + } + return Promise.reject(msg); + } +}; +export const saveAdvancedClaim = async (data: { + id: string; + userId: string; + name?: string; + description?: string; + city?: string; + finished?: boolean; + active?: boolean; + builders?: { id: string }[]; +}): Promise => { + try { + const claim = await prisma.claim.findFirst({ + where: { id: data.id, owner: { ssoId: data.userId } }, + }); + + if (!claim) { + return Promise.reject('Claim not found or you do not have permission to edit this claim.'); + } + + const claim2 = await prisma.claim.update({ + where: { id: data.id, owner: { ssoId: data.userId } }, + data: { + name: data.name, + description: data.description, + city: data.city, + finished: data.finished, + active: data.active, + builders: data.builders ? { set: data.builders.map((b) => ({ id: b.id })) } : undefined, + }, + }); + + revalidatePath(`/editor/${data.id}`); + return; + } catch (e) { + let msg = 'Unknown error'; + if (e instanceof Prisma.PrismaClientKnownRequestError) { + msg = e.code; + if (e.code === 'P2025') { + msg = 'Claim not found or you do not have permission to edit this claim.'; + } + } + return Promise.reject(msg); + } +}; +export const createClaim = async (data: { + id: string; + userId: string; + area: string[]; + buildTeamId: string; +}): Promise => { + try { + const buildTeam = await prisma.buildTeam.findFirst({ + where: { id: data.buildTeamId, members: { some: { ssoId: data.userId } }, allowBuilderClaim: true }, + }); + + if (!buildTeam) { + return Promise.reject('You do not have permission to create a claim in this BuildTeam.'); + } + + let center = undefined; + if (data.area?.length > 0) { + center = turf.center(toPolygon(data.area)).geometry.coordinates.join(', '); + } + + const buildingCount = await updateClaimBuildingCount({ area: data.area }); + + if (typeof buildingCount !== 'number') { + if (buildingCount && typeof (buildingCount as { message?: string }).message === 'string') { + return Promise.reject((buildingCount as { message: string }).message); + } + return Promise.reject('Failed to set building count for claim.'); + } + + let osmDetails = undefined; + + if (center) { + osmDetails = await updateClaimOSMDetails({ id: data.id, center }); + if (!osmDetails) { + return Promise.reject('Failed to set OSM details for claim.'); + } + } + + const claim = await prisma.claim.create({ + data: { + id: data.id, + owner: { connect: { ssoId: data.userId } }, + buildTeam: { connect: { id: data.buildTeamId } }, + area: data.area, + center: center, + buildings: buildingCount, + active: true, + finished: false, + ...osmDetails, + }, + }); + + revalidatePath('/editor'); + return; + } catch (e) { + let msg = 'Unknown error'; + if (e instanceof Error) { + msg = e.message; + throw e; + } + if (e instanceof Prisma.PrismaClientKnownRequestError) { + msg = e.code; + if (e.code === 'P2025') { + msg = 'Claim not found or you do not have permission to edit this claim.'; + } + } + return Promise.reject(msg); + } +}; +export const deleteClaim = async (data: { id: string; userId: string }): Promise => { + try { + const claim = await prisma.claim.findFirst({ + where: { id: data.id, owner: { ssoId: data.userId } }, + }); + + if (!claim) { + return Promise.reject('Claim not found or you do not have permission to delete this claim.'); + } + + await prisma.claim.delete({ + where: { id: data.id, owner: { ssoId: data.userId } }, + }); + + revalidatePath('/editor'); + return; + } catch (e) { + let msg = 'Unknown error'; + if (e instanceof Prisma.PrismaClientKnownRequestError) { + msg = e.code; + if (e.code === 'P2025') { + msg = 'Claim not found or you do not have permission to delete this claim.'; + } + } + return Promise.reject(msg); + } +}; +export const transferClaim = async (data: { id: string; userId: string; newUserId: string }): Promise => { + try { + const claim = await prisma.claim.findFirst({ + where: { id: data.id, owner: { ssoId: data.userId } }, + include: { builders: { select: { id: true } } }, + }); + + if (!claim) { + return Promise.reject('Claim not found or you do not have permission to edit this claim.'); + } + + await prisma.claim.update({ + where: { id: data.id, owner: { ssoId: data.userId } }, + data: { + owner: { connect: { id: data.newUserId } }, + builders: { + set: [ + ...(claim.builders.filter((b: { id: string }) => b.id != data.newUserId) || []), + ...(claim.ownerId ? [{ id: claim.ownerId }] : []), + ], + }, + }, + }); + + revalidatePath('/editor'); + return; + } catch (e) { + let msg = 'Unknown error'; + if (e instanceof Prisma.PrismaClientKnownRequestError) { + msg = e.code; + if (e.code === 'P2025') { + msg = 'Claim not found or you do not have permission to delete this claim.'; + } + } + return Promise.reject(msg); + } +}; + +// export const createClaim = async (data: { +// id: string; +// userId: string; +// area: string[]; +// finished?: boolean; +// active?: boolean; +// description?: string; +// buildTeamId: string; +// city?: string; +// name?: string; +// }): Promise => { +// try { +// if (!data.area || data.area.length == 0) { +// return Promise.reject('Claim area is required.'); +// } + +// const buildteam = await prisma.buildTeam.findUnique({ +// where: { id: data.buildTeamId }, +// select: { +// allowBuilderClaim: true, +// id: true, +// members: { where: { ssoId: data.userId } }, +// }, +// }); + +// if (!buildteam) { +// return Promise.reject('BuildTeam not found.'); +// } + +// if (buildteam.allowBuilderClaim === false) { +// return Promise.reject('BuildTeam does not allow claims.'); +// } + +// if (buildteam.members.length <= 0) { +// return Promise.reject('You are not a member of this BuildTeam.'); +// } + +// let center = turf.center(toPolygon(data.area)).geometry.coordinates.join(', '); + +// const buildingCount = data.area && (await updateClaimBuildingCount({ area: data.area })); + +// if (typeof buildingCount !== 'number') { +// if (buildingCount && typeof (buildingCount as { message?: string }).message === 'string') { +// return Promise.reject((buildingCount as { message: string }).message); +// } +// return Promise.reject('Failed to get building count for claim.'); +// } + +// let osmDetails = await updateClaimOSMDetails({ id: data.id, name: data.name, center }); + +// if (!osmDetails) { +// return Promise.reject('Failed to update OSM details for claim.'); +// } + +// const claim = await prisma.claim.create({ +// data: { +// buildTeam: { connect: { id: data.buildTeamId } }, +// id: data.id, +// owner: { connect: { ssoId: data.userId } }, +// area: data.area, +// center: center, +// finished: data.finished, +// active: data.active, +// description: data.description, +// buildings: buildingCount, +// ...osmDetails, +// }, +// include: { +// buildTeam: { +// select: { +// webhook: true, +// }, +// }, +// }, +// }); + +// await sendBtWebhook(claim.buildTeam.webhook, WebhookType.CLAIM_CREATE, { +// ...claim, +// buildTeam: undefined, +// }); + +// revalidatePath('/editor'); +// return; +// } catch (e) { +// let msg = 'Unknown error'; +// if (e instanceof Prisma.PrismaClientKnownRequestError) { +// msg = e.code; +// if (e.code === 'P2025') { +// msg = 'Claim not found or you do not have permission to edit this claim.'; +// } +// } +// return Promise.reject(msg); +// } +// }; diff --git a/apps/dashboard/src/actions/getUser.ts b/apps/dashboard/src/actions/getUser.ts index 6799c367..4ab5575c 100644 --- a/apps/dashboard/src/actions/getUser.ts +++ b/apps/dashboard/src/actions/getUser.ts @@ -1,6 +1,46 @@ -import { User } from '@/types/User'; -import { authedFetcher } from '@/util/data'; +import { getSession } from '@/util/auth'; +import prisma from '@/util/db'; +import { cache } from 'react'; export const getUser = async () => { - return authedFetcher('/account'); + const session = await getSession(); + if (!session) throw Error('No session found'); + + const user = await cache( + async (id: string) => + await prisma.user.findFirst({ + where: { ssoId: id }, + select: { + id: true, + ssoId: true, + username: true, + discordId: true, + minecraft: true, + avatar: true, + }, + }), + )(session.user.id); + + if (!user) throw Error('User not found'); + + return user!; }; + +export const getUserPermissions = cache(async (ssoId?: string) => { + if (!ssoId) { + const session = await getSession(); + if (!session) throw Error('No session found'); + ssoId = session.user.id; + } + + const permissions = await prisma.userPermission.findMany({ + where: { + user: { ssoId }, + }, + include: { + permission: true, + buildTeam: { select: { id: true, slug: true, name: true } }, + }, + }); + return permissions; +}); diff --git a/apps/dashboard/src/actions/uploads.ts b/apps/dashboard/src/actions/uploads.ts index f9d04fb4..ba59071c 100644 --- a/apps/dashboard/src/actions/uploads.ts +++ b/apps/dashboard/src/actions/uploads.ts @@ -1,9 +1,16 @@ 'use server'; +import { getSession, hasRole } from '@/util/auth'; import { revalidateWebsitePath } from '@/util/data'; import prisma from '@/util/db'; import { revalidatePath } from 'next/cache'; export const adminCheckUpload = async (id: string) => { + const session = await getSession(); + + if (!hasRole(session, 'review-uploads')) { + throw Error('Unauthorized'); + } + const upload = await prisma.upload.update({ where: { id, @@ -17,6 +24,12 @@ export const adminCheckUpload = async (id: string) => { }; export const adminDeleteUpload = async (id: string) => { + const session = await getSession(); + + if (!hasRole(session, 'review-uploads')) { + throw Error('Unauthorized'); + } + const upload = await prisma.upload.delete({ where: { id, @@ -26,3 +39,20 @@ export const adminDeleteUpload = async (id: string) => { revalidatePath('/am/uploads/check'); revalidateWebsitePath('/gallery'); }; + +export const adminApproveShowcase = async (id: string) => { + const session = await getSession(); + + if (!hasRole(session, 'review-uploads')) { + throw Error('Unauthorized'); + } + + const showcase = await prisma.showcase.update({ + where: { + id, + }, + data: { + approved: true, + }, + }); +}; diff --git a/apps/dashboard/src/actions/user.ts b/apps/dashboard/src/actions/user.ts new file mode 100644 index 00000000..d5b247a2 --- /dev/null +++ b/apps/dashboard/src/actions/user.ts @@ -0,0 +1,264 @@ +'use server'; +import { getSession, hasRole } from '@/util/auth'; +import prisma from '@/util/db'; +import keycloakAdmin from '@/util/keycloak'; +import { revalidatePath } from 'next/cache'; + +const requireEditUsersPermission = async () => { + const session = await getSession(); + + if (!hasRole(session, 'edit-users')) { + return { status: 'error', error: 'Unauthorized' }; + } + + return null; +}; + +export const getUserBuildTeams = async (ssoId: string) => { + const buildteams = await prisma.buildTeam.findMany({ + where: { + OR: [ + { creator: { ssoId } }, + { + UserPermission: { + some: { + user: { + ssoId, + }, + }, + }, + }, + ], + }, + select: { + id: true, + name: true, + slug: true, + creatorId: true, + icon: true, + UserPermission: { + where: { user: { ssoId } }, + select: { + permission: { + select: { + id: true, + }, + }, + id: true, + }, + }, + }, + }); + + return buildteams; +}; + +export const editOwnProfile = async ( + prevState: any, + data: { email: string; username: string; ssoId: string }, +): Promise => { + try { + const user = await prisma.user.update({ + where: { ssoId: data.ssoId }, + data: { username: data.username }, + }); + await keycloakAdmin.users.update({ id: user.ssoId }, { username: data.username, email: data.email }); + const kcUser = await keycloakAdmin.users.findOne({ id: user.ssoId }); + return { status: 'success', user }; + } catch (error) { + console.error('Error updating user:', error); + return { status: 'error', error: 'Failed to update user' }; + } +}; + +export const adminRemoveFromTeam = async (prevState: any, data: { ssoId: string; slug: string }): Promise => { + try { + const authorizationError = await requireEditUsersPermission(); + if (authorizationError) return authorizationError; + + const user = await prisma.user.findUnique({ + where: { ssoId: data.ssoId }, + include: { joinedBuildTeams: true }, + }); + if (!user) { + return { status: 'error', error: 'User not found' }; + } + const team = user.joinedBuildTeams.find((t) => t.slug === data.slug); + if (!team) { + return { status: 'error', error: 'Team not found' }; + } + + // Prevent removing the creator from their own team + if (team.creatorId === user.id) { + return { status: 'error', error: 'Cannot remove the creator from their own team' }; + } + + await prisma.buildTeam.update({ + where: { id: team.id }, + data: { + members: { + disconnect: { id: user.id }, + }, + }, + }); + await prisma.userPermission.deleteMany({ + where: { + userId: user.id, + buildTeamId: team.id, + }, + }); + + revalidatePath(`/am/users/${data.ssoId}`); + return { status: 'success', message: 'User removed from team successfully' }; + } catch (error) { + console.error('Error removing user from team:', error); + return { status: 'error', error: 'Failed to remove user from team' }; + } +}; + +export const adminAddToTeam = async (prevState: any, data: { ssoId: string; slug: string }): Promise => { + try { + const authorizationError = await requireEditUsersPermission(); + if (authorizationError) return authorizationError; + + const user = await prisma.user.findUnique({ + where: { ssoId: data.ssoId }, + include: { joinedBuildTeams: true }, + }); + if (!user) { + return { status: 'error', error: 'User not found' }; + } + + const team = await prisma.buildTeam.findUnique({ + where: { slug: data.slug }, + }); + if (!team) { + return { status: 'error', error: 'Team not found' }; + } + + await prisma.buildTeam.update({ + where: { id: team.id }, + data: { + members: { + connect: { id: user.id }, + }, + }, + }); + + revalidatePath(`/am/users/${data.ssoId}`); + return { + status: 'success', + message: 'User added to team successfully', + team: { name: team.name, slug: team.slug }, + }; + } catch (error) { + console.error('Error adding user to team:', error); + return { status: 'error', error: 'Failed to add user to team' }; + } +}; + +export const adminAddPermissions = async ( + prevState: any, + data: { ssoId: string; permissions: string[]; team?: string }, +): Promise => { + try { + const authorizationError = await requireEditUsersPermission(); + if (authorizationError) return authorizationError; + + const user = await prisma.user.findUnique({ + where: { ssoId: data.ssoId }, + include: { joinedBuildTeams: true }, + }); + if (!user) { + return { status: 'error', error: 'User not found' }; + } + + let team: string | undefined = undefined; + + if (data.team) { + team = ( + await prisma.buildTeam.findUnique({ + where: { slug: data.team }, + select: { id: true }, + }) + )?.id; + + if (!team) { + return { status: 'error', error: 'BuildTeam not found' }; + } + } + + await prisma.userPermission.createMany({ + data: data.permissions.map((permission) => ({ + userId: user.id, + permissionId: permission, + buildTeamId: team ? team : null, + })), + }); + + revalidatePath(`/am/users/${data.ssoId}`); + return { + status: 'success', + message: 'Permissions added to user successfully', + }; + } catch (error) { + console.error('Error adding permissions to user:', error); + return { status: 'error', error: 'Failed to add permissions to user' }; + } +}; + +export const adminRemovePermission = async ( + prevState: any, + data: { ssoId: string; userPermission: string }, +): Promise => { + try { + const authorizationError = await requireEditUsersPermission(); + if (authorizationError) return authorizationError; + + const user = await prisma.user.findUnique({ + where: { ssoId: data.ssoId }, + include: { joinedBuildTeams: true }, + }); + if (!user) { + return { status: 'error', error: 'User not found' }; + } + + const team: string | undefined = undefined; + + await prisma.userPermission.delete({ + where: { + id: data.userPermission, + }, + }); + + revalidatePath(`/am/users/${data.ssoId}`); + return { + status: 'success', + message: 'Permissions removed successfully', + }; + } catch (error) { + console.error('Error removing permission from user:', error); + return { status: 'error', error: 'Failed to remove permission from user' }; + } +}; + +export const adminInvalidateUserSessions = async (prevState: any, ssoId: string): Promise => { + try { + const authorizationError = await requireEditUsersPermission(); + if (authorizationError) return authorizationError; + + const user = await prisma.user.findUnique({ + where: { ssoId }, + }); + + if (!user) { + return { status: 'error', error: 'User not found' }; + } + + await keycloakAdmin.users.logout({ id: user.ssoId }); + return { status: 'success', message: 'User sessions invalidated successfully' }; + } catch (error) { + console.error('Error invalidating user sessions:', error); + return { status: 'error', error: 'Failed to invalidate user sessions' }; + } +}; diff --git a/apps/dashboard/src/app/(editorNavbar)/editor/[id]/interactivity.tsx b/apps/dashboard/src/app/(editorNavbar)/editor/[id]/interactivity.tsx new file mode 100644 index 00000000..e4324f84 --- /dev/null +++ b/apps/dashboard/src/app/(editorNavbar)/editor/[id]/interactivity.tsx @@ -0,0 +1,240 @@ +'use client'; +import { UserDisplay } from '@/components/data/User'; +import { UserSelect } from '@/components/input/UserSelect'; +import { + ActionIcon, + Badge, + Box, + Button, + Code, + Menu, + MenuDivider, + MenuDropdown, + MenuItem, + MenuTarget, + rem, + SimpleGrid, + Skeleton, + Switch, + Table, + Textarea, + TextInput, + Title, +} from '@mantine/core'; +import { IconDeviceFloppy, IconDots, IconTransfer, IconTrash } from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; +import { useEffect } from 'react'; +import { AdvancedClaimEditorClaim, useAdvancedClaimEditorStore } from './store'; + +export function AdvancedEditor({ initialClaim }: { initialClaim: AdvancedClaimEditorClaim | null }) { + const { claim, setClaim, setUserId, updateClaim, saveChanges, transferOwnership } = useAdvancedClaimEditorStore(); + const session = useSession(); + + useEffect(() => { + if (initialClaim && initialClaim.id != claim?.id) { + setClaim(initialClaim); + setUserId(session?.data?.user.id || 'XXXXX'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialClaim]); + + return ( + + + + Claim Details + + updateClaim({ name: e.currentTarget.value })} + mb="md" + /> +