diff --git a/backend/.env.template b/backend/.env.template index b9ec83f94..dfe26d401 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -49,3 +49,5 @@ IMAGOR_SECRET=mysecret CATEGORIES_ACTIVE=false MAX_PINNED_POSTS=1 MAX_GROUP_PINNED_POSTS=1 +API_KEYS_ENABLED=false +API_KEYS_MAX_PER_USER=5 diff --git a/backend/.env.test_e2e b/backend/.env.test_e2e index 62bf7bab7..cbdafdd58 100644 --- a/backend/.env.test_e2e +++ b/backend/.env.test_e2e @@ -41,3 +41,5 @@ IMAGOR_SECRET=mysecret CATEGORIES_ACTIVE=false MAX_PINNED_POSTS=1 MAX_GROUP_PINNED_POSTS=1 +API_KEYS_ENABLED=true +API_KEYS_MAX_PER_USER=5 diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 57d9b6e61..e4586d078 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -148,6 +148,8 @@ const options = { MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS)) ? 1 : Number(process.env.MAX_GROUP_PINNED_POSTS), + API_KEYS_ENABLED: env.API_KEYS_ENABLED === 'true', + API_KEYS_MAX_PER_USER: (env.API_KEYS_MAX_PER_USER && parseInt(env.API_KEYS_MAX_PER_USER)) || 5, } const language = { diff --git a/backend/src/db/models/ApiKey.ts b/backend/src/db/models/ApiKey.ts new file mode 100644 index 000000000..c7319b028 --- /dev/null +++ b/backend/src/db/models/ApiKey.ts @@ -0,0 +1,16 @@ +export default { + id: { type: 'uuid', primary: true }, + name: { type: 'string', required: true }, + keyHash: { type: 'string', unique: 'true', required: true }, + keyPrefix: { type: 'string', required: true }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + lastUsedAt: { type: 'string', isoDate: true }, + expiresAt: { type: 'string', isoDate: true }, + disabled: { type: 'boolean', default: false }, + owner: { + type: 'relationship', + relationship: 'HAS_API_KEY', + target: 'User', + direction: 'in', + }, +} diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index ca5b76f87..cab004c01 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -4,6 +4,7 @@ // We use static imports instead of dynamic require() to ensure compatibility // with both Node.js and Webpack (used by Cypress cucumber preprocessor). +import ApiKey from './ApiKey' import Badge from './Badge' import Category from './Category' import Comment from './Comment' @@ -29,6 +30,7 @@ import type Neode from 'neode' // SchemaObject type with PropertyTypes union. The Neode type definitions are // incomplete/incorrect, so we use double assertion to bypass the check. export default { + ApiKey, Badge, Category, Comment, diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 11a4960eb..52b91d3d5 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -828,6 +828,118 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, ) + // eslint-disable-next-line no-console + console.log('seed', 'api-keys') + + // API Keys for Peter (admin) — active keys + await database.write({ + query: `MATCH (u:User { id: 'u1' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-peter-ci', name: 'CI Bot', keyPrefix: 'oak_peterCI', + keyHash: 'seed-hash-peter-ci', createdAt: toString(datetime() - duration('P30D')), + lastUsedAt: toString(datetime() - duration('PT2H')), disabled: false + })`, + variables: {}, + }) + await database.write({ + query: `MATCH (u:User { id: 'u1' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-peter-backup', name: 'Backup Script', keyPrefix: 'oak_peterBU', + keyHash: 'seed-hash-peter-backup', createdAt: toString(datetime() - duration('P14D')), + lastUsedAt: toString(datetime() - duration('P3D')), disabled: false + })`, + variables: {}, + }) + // Peter's revoked key + await database.write({ + query: `MATCH (u:User { id: 'u1' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-peter-old', name: 'Old Script', keyPrefix: 'oak_peterOL', + keyHash: 'seed-hash-peter-old', createdAt: '2025-01-01T00:00:00.000Z', + lastUsedAt: '2025-05-15T00:00:00.000Z', + disabled: true, disabledAt: '2025-06-01T00:00:00.000Z' + })`, + variables: {}, + }) + + // API Key for Jenny (user) — active, with expiry + await database.write({ + query: `MATCH (u:User { id: 'u3' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-jenny-bot', name: 'Weather Bot', keyPrefix: 'oak_jennyWB', + keyHash: 'seed-hash-jenny-bot', createdAt: toString(datetime() - duration('P7D')), + lastUsedAt: toString(datetime() - duration('PT30M')), disabled: false, + expiresAt: toString(datetime() + duration('P365D')) + })`, + variables: {}, + }) + + // API Key for Huey (user) — active, all his posts created via this key + await database.write({ + query: `MATCH (u:User { id: 'u4' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-huey-auto', name: 'Auto Publisher', keyPrefix: 'oak_hueyAUT', + keyHash: 'seed-hash-huey-auto', createdAt: toString(datetime() - duration('P60D')), + lastUsedAt: toString(datetime() - duration('PT5M')), disabled: false + })`, + variables: {}, + }) + + // API Key for Bob (moderator) — active + await database.write({ + query: `MATCH (u:User { id: 'u2' }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-bob-monitor', name: 'Monitoring', keyPrefix: 'oak_bobMON', + keyHash: 'seed-hash-bob-monitor', createdAt: toString(datetime()), + disabled: false + })`, + variables: {}, + }) + + // Create some posts via API key (Peter's CI Bot) + authenticatedUser = { ...(await peterLustig.toJson()), apiKeyId: 'ak-peter-ci' } + await mutate({ + mutation: CreatePost, + variables: { + id: 'p-api-1', + title: 'Automated Daily Report', + content: 'This post was created automatically via API key by the CI Bot.', + categoryIds: ['cat16'], + }, + }) + await mutate({ + mutation: CreatePost, + variables: { + id: 'p-api-2', + title: 'Weekly Statistics Summary', + content: 'Automated weekly summary of community statistics.', + categoryIds: ['cat9'], + }, + }) + + // Jenny's Weather Bot creates a post + authenticatedUser = { ...(await jennyRostock.toJson()), apiKeyId: 'ak-jenny-bot' } + await mutate({ + mutation: CreatePost, + variables: { + id: 'p-api-3', + title: 'Weather Report Paris', + content: 'Sunny, 22°C. Perfect day for a walk along the Seine.', + categoryIds: ['cat4'], + }, + }) + // Jenny's bot also comments + await mutate({ + mutation: CreateComment, + variables: { + id: 'c-api-1', + postId: 'p-api-1', + content: 'Automated cross-reference: See also the weather report.', + }, + }) + + authenticatedUser = null + // eslint-disable-next-line no-console console.log('seed', 'invitecodes') @@ -1753,6 +1865,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ) } + // Mark all Huey's posts and comments as created via API key + await database.write({ + query: `MATCH (u:User { id: 'u4' })-[:WROTE]->(content) + WHERE content:Post OR content:Comment + SET content.createdByApiKey = 'ak-huey-auto'`, + variables: {}, + }) + await Factory.build('donations') // eslint-disable-next-line no-console diff --git a/backend/src/graphql/queries/apiKeys/adminRevokeApiKey.gql b/backend/src/graphql/queries/apiKeys/adminRevokeApiKey.gql new file mode 100644 index 000000000..0982ea10a --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/adminRevokeApiKey.gql @@ -0,0 +1,3 @@ +mutation adminRevokeApiKey($id: ID!) { + adminRevokeApiKey(id: $id) +} diff --git a/backend/src/graphql/queries/apiKeys/adminRevokeUserApiKeys.gql b/backend/src/graphql/queries/apiKeys/adminRevokeUserApiKeys.gql new file mode 100644 index 000000000..81b223d60 --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/adminRevokeUserApiKeys.gql @@ -0,0 +1,3 @@ +mutation adminRevokeUserApiKeys($userId: ID!) { + adminRevokeUserApiKeys(userId: $userId) +} diff --git a/backend/src/graphql/queries/apiKeys/apiKeyUsers.gql b/backend/src/graphql/queries/apiKeys/apiKeyUsers.gql new file mode 100644 index 000000000..7077c78f1 --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/apiKeyUsers.gql @@ -0,0 +1,14 @@ +query apiKeyUsers($first: Int, $offset: Int) { + apiKeyUsers(first: $first, offset: $offset) { + user { + id + name + slug + } + activeCount + revokedCount + postsCount + commentsCount + lastActivity + } +} diff --git a/backend/src/graphql/queries/apiKeys/apiKeysForUser.gql b/backend/src/graphql/queries/apiKeys/apiKeysForUser.gql new file mode 100644 index 000000000..180253653 --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/apiKeysForUser.gql @@ -0,0 +1,12 @@ +query apiKeysForUser($userId: ID!) { + apiKeysForUser(userId: $userId) { + id + name + keyPrefix + createdAt + lastUsedAt + expiresAt + disabled + disabledAt + } +} diff --git a/backend/src/graphql/queries/apiKeys/createApiKey.gql b/backend/src/graphql/queries/apiKeys/createApiKey.gql new file mode 100644 index 000000000..31c6c1313 --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/createApiKey.gql @@ -0,0 +1,15 @@ +mutation createApiKey($name: String!, $expiresInDays: Int) { + createApiKey(name: $name, expiresInDays: $expiresInDays) { + apiKey { + id + name + keyPrefix + createdAt + expiresAt + disabled + disabledAt + lastUsedAt + } + secret + } +} diff --git a/backend/src/graphql/queries/apiKeys/myApiKeys.gql b/backend/src/graphql/queries/apiKeys/myApiKeys.gql new file mode 100644 index 000000000..34bec2f18 --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/myApiKeys.gql @@ -0,0 +1,12 @@ +query myApiKeys { + myApiKeys { + id + name + keyPrefix + createdAt + lastUsedAt + expiresAt + disabled + disabledAt + } +} diff --git a/backend/src/graphql/queries/apiKeys/revokeApiKey.gql b/backend/src/graphql/queries/apiKeys/revokeApiKey.gql new file mode 100644 index 000000000..076c862da --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/revokeApiKey.gql @@ -0,0 +1,3 @@ +mutation revokeApiKey($id: ID!) { + revokeApiKey(id: $id) +} diff --git a/backend/src/graphql/queries/apiKeys/updateApiKey.gql b/backend/src/graphql/queries/apiKeys/updateApiKey.gql new file mode 100644 index 000000000..cca3a160d --- /dev/null +++ b/backend/src/graphql/queries/apiKeys/updateApiKey.gql @@ -0,0 +1,12 @@ +mutation updateApiKey($id: ID!, $name: String!) { + updateApiKey(id: $id, name: $name) { + id + name + keyPrefix + createdAt + lastUsedAt + expiresAt + disabled + disabledAt + } +} diff --git a/backend/src/graphql/resolvers/apiKeys.spec.ts b/backend/src/graphql/resolvers/apiKeys.spec.ts new file mode 100644 index 000000000..aaa7e2ed6 --- /dev/null +++ b/backend/src/graphql/resolvers/apiKeys.spec.ts @@ -0,0 +1,493 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { cleanDatabase } from '@db/factories' +import adminRevokeApiKey from '@graphql/queries/apiKeys/adminRevokeApiKey.gql' +import adminRevokeUserApiKeys from '@graphql/queries/apiKeys/adminRevokeUserApiKeys.gql' +import apiKeysForUser from '@graphql/queries/apiKeys/apiKeysForUser.gql' +import apiKeyUsers from '@graphql/queries/apiKeys/apiKeyUsers.gql' +import createApiKey from '@graphql/queries/apiKeys/createApiKey.gql' +import myApiKeys from '@graphql/queries/apiKeys/myApiKeys.gql' +import revokeApiKey from '@graphql/queries/apiKeys/revokeApiKey.gql' +import updateApiKey from '@graphql/queries/apiKeys/updateApiKey.gql' +import { createApolloTestSetup } from '@root/test/helpers' + +import type { ApolloTestSetup } from '@root/test/helpers' +import type { Context } from '@src/context' + +let authenticatedUser: Context['user'] +const context = () => ({ + authenticatedUser, + config: { API_KEYS_ENABLED: true, API_KEYS_MAX_PER_USER: 3 }, +}) +let query: ApolloTestSetup['query'] +let mutate: ApolloTestSetup['mutate'] +let database: ApolloTestSetup['database'] +let server: ApolloTestSetup['server'] + +beforeAll(async () => { + await cleanDatabase() + const apolloSetup = await createApolloTestSetup({ context }) + query = apolloSetup.query + mutate = apolloSetup.mutate + database = apolloSetup.database + server = apolloSetup.server +}) + +afterAll(async () => { + await cleanDatabase() + void server.stop() + void database.driver.close() + database.neode.close() +}) + +afterEach(async () => { + await cleanDatabase() +}) + +describe('createApiKey', () => { + describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Test Key' }, + }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + const user = await database.neode.create('User', { + id: 'u1', + name: 'Test User', + slug: 'test-user', + role: 'user', + }) + authenticatedUser = (await user.toJson()) as Context['user'] + }) + + it('creates an API key and returns secret with oak_ prefix', async () => { + const { data, errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'My CI Key' }, + }) + expect(errors).toBeUndefined() + expect(data.createApiKey.secret).toMatch(/^oak_/) + expect(data.createApiKey.apiKey).toMatchObject({ + name: 'My CI Key', + disabled: false, + disabledAt: null, + lastUsedAt: null, + }) + expect(data.createApiKey.apiKey.keyPrefix).toMatch(/^oak_/) + expect(data.createApiKey.apiKey.id).toBeTruthy() + expect(data.createApiKey.apiKey.createdAt).toBeTruthy() + }) + + it('creates a key with expiry', async () => { + const { data, errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Expiring Key', expiresInDays: 30 }, + }) + expect(errors).toBeUndefined() + expect(data.createApiKey.apiKey.expiresAt).toBeTruthy() + }) + + it('creates a key without expiry', async () => { + const { data, errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Permanent Key' }, + }) + expect(errors).toBeUndefined() + expect(data.createApiKey.apiKey.expiresAt).toBeNull() + }) + + it('enforces max keys per user', async () => { + await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } }) + await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) + await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } }) + const { errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Key 4' }, + }) + expect(errors?.[0].message).toContain('Maximum of 3 active API keys reached') + }) + + it('rejects expiresInDays of 0', async () => { + const { errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Zero Expiry', expiresInDays: 0 }, + }) + expect(errors?.[0].message).toContain('expiresInDays must be a positive integer') + }) + + it('rejects negative expiresInDays', async () => { + const { errors } = await mutate({ + mutation: createApiKey, + variables: { name: 'Negative Expiry', expiresInDays: -5 }, + }) + expect(errors?.[0].message).toContain('expiresInDays must be a positive integer') + }) + + it('does not count revoked keys towards the limit', async () => { + const { data: k1 } = await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } }) + await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) + await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } }) + // Revoke one + await mutate({ mutation: revokeApiKey, variables: { id: k1.createApiKey.apiKey.id } }) + // Should succeed now + const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Key 4' } }) + expect(errors).toBeUndefined() + }) + }) + + describe('API keys disabled', () => { + it('throws error when feature is disabled', async () => { + const user = await database.neode.create('User', { + id: 'u-disabled', + name: 'Disabled User', + slug: 'disabled-user', + role: 'user', + }) + authenticatedUser = (await user.toJson()) as Context['user'] + const contextDisabled = () => ({ + authenticatedUser, + config: { API_KEYS_ENABLED: false, API_KEYS_MAX_PER_USER: 5 }, + }) + const setup = await createApolloTestSetup({ context: contextDisabled }) + const { errors } = await setup.mutate({ + mutation: createApiKey, + variables: { name: 'Should Fail' }, + }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + void setup.server.stop() + }) + }) +}) + +describe('myApiKeys', () => { + beforeEach(async () => { + const user = await database.neode.create('User', { + id: 'u2', + name: 'Key Owner', + slug: 'key-owner', + role: 'user', + }) + authenticatedUser = (await user.toJson()) as Context['user'] + }) + + it('returns empty list when no keys exist', async () => { + const { data, errors } = await query({ query: myApiKeys }) + expect(errors).toBeUndefined() + expect(data.myApiKeys).toEqual([]) + }) + + it('returns created keys ordered by createdAt desc', async () => { + await mutate({ mutation: createApiKey, variables: { name: 'Key A' } }) + await mutate({ mutation: createApiKey, variables: { name: 'Key B' } }) + const { data, errors } = await query({ query: myApiKeys }) + expect(errors).toBeUndefined() + expect(data.myApiKeys).toHaveLength(2) + // Most recent first + expect(data.myApiKeys[0].name).toBe('Key B') + expect(data.myApiKeys[1].name).toBe('Key A') + }) + + it('includes revoked keys', async () => { + const { data: created } = await mutate({ + mutation: createApiKey, + variables: { name: 'To Revoke' }, + }) + await mutate({ mutation: revokeApiKey, variables: { id: created.createApiKey.apiKey.id } }) + const { data } = await query({ query: myApiKeys }) + expect(data.myApiKeys).toHaveLength(1) + expect(data.myApiKeys[0].disabled).toBe(true) + expect(data.myApiKeys[0].disabledAt).toBeTruthy() + }) +}) + +describe('updateApiKey', () => { + let keyId: string + + beforeEach(async () => { + const user = await database.neode.create('User', { + id: 'u3', + name: 'Updater', + slug: 'updater', + role: 'user', + }) + authenticatedUser = (await user.toJson()) as Context['user'] + const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Original' } }) + keyId = data.createApiKey.apiKey.id + }) + + it('renames a key', async () => { + const { data, errors } = await mutate({ + mutation: updateApiKey, + variables: { id: keyId, name: 'Renamed' }, + }) + expect(errors).toBeUndefined() + expect(data.updateApiKey.name).toBe('Renamed') + }) + + it('throws error for nonexistent key', async () => { + const { errors } = await mutate({ + mutation: updateApiKey, + variables: { id: 'nonexistent', name: 'Fail' }, + }) + expect(errors?.[0].message).toContain('API key not found') + }) + + it("throws error for another user's key", async () => { + const otherUser = await database.neode.create('User', { + id: 'u-stranger', + name: 'Stranger', + slug: 'stranger', + role: 'user', + }) + authenticatedUser = (await otherUser.toJson()) as Context['user'] + const { errors } = await mutate({ + mutation: updateApiKey, + variables: { id: keyId, name: 'Stolen' }, + }) + expect(errors?.[0].message).toContain('API key not found') + }) +}) + +describe('revokeApiKey', () => { + let keyId: string + + beforeEach(async () => { + const user = await database.neode.create('User', { + id: 'u4', + name: 'Revoker', + slug: 'revoker', + role: 'user', + }) + authenticatedUser = (await user.toJson()) as Context['user'] + const { data } = await mutate({ mutation: createApiKey, variables: { name: 'To Revoke' } }) + keyId = data.createApiKey.apiKey.id + }) + + it('revokes own key', async () => { + const { data, errors } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + expect(errors).toBeUndefined() + expect(data.revokeApiKey).toBe(true) + }) + + it('sets disabledAt on revoked key', async () => { + await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + const { data } = await query({ query: myApiKeys }) + const revoked = data.myApiKeys.find((k) => k.id === keyId) + expect(revoked.disabled).toBe(true) + expect(revoked.disabledAt).toBeTruthy() + }) + + it('returns false for nonexistent key', async () => { + const { data } = await mutate({ mutation: revokeApiKey, variables: { id: 'nonexistent' } }) + expect(data.revokeApiKey).toBe(false) + }) + + it('returns false when revoking an already revoked key', async () => { + await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + expect(data.revokeApiKey).toBe(false) + }) + + it('preserves original disabledAt on double revoke', async () => { + await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + const { data: before } = await query({ query: myApiKeys }) + const originalDisabledAt = before.myApiKeys.find((k) => k.id === keyId).disabledAt + await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + const { data: after } = await query({ query: myApiKeys }) + expect(after.myApiKeys.find((k) => k.id === keyId).disabledAt).toBe(originalDisabledAt) + }) + + it("returns false for another user's key", async () => { + const otherUser = await database.neode.create('User', { + id: 'u4-other', + name: 'Other', + slug: 'other', + role: 'user', + }) + authenticatedUser = (await otherUser.toJson()) as Context['user'] + const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) + expect(data.revokeApiKey).toBe(false) + }) +}) + +describe('admin operations', () => { + let regularUser, adminUser + let keyId: string + + beforeEach(async () => { + regularUser = await database.neode.create('User', { + id: 'u-regular', + name: 'Regular', + slug: 'regular', + role: 'user', + }) + adminUser = await database.neode.create('User', { + id: 'u-admin', + name: 'Admin', + slug: 'admin', + role: 'admin', + }) + authenticatedUser = (await regularUser.toJson()) as Context['user'] + const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Regular Key' } }) + keyId = data.createApiKey.apiKey.id + }) + + describe('adminRevokeApiKey', () => { + it('non-admin cannot revoke', async () => { + authenticatedUser = (await regularUser.toJson()) as Context['user'] + const { errors } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + }) + + it('admin can revoke any key', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data, errors } = await mutate({ + mutation: adminRevokeApiKey, + variables: { id: keyId }, + }) + expect(errors).toBeUndefined() + expect(data.adminRevokeApiKey).toBe(true) + }) + + it('returns false when admin revokes an already revoked key', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) + const { data } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) + expect(data.adminRevokeApiKey).toBe(false) + }) + }) + + describe('adminRevokeUserApiKeys', () => { + beforeEach(async () => { + authenticatedUser = (await regularUser.toJson()) as Context['user'] + await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) + }) + + it('non-admin cannot bulk revoke', async () => { + authenticatedUser = (await regularUser.toJson()) as Context['user'] + const { errors } = await mutate({ + mutation: adminRevokeUserApiKeys, + variables: { userId: 'u-regular' }, + }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + }) + + it('admin can revoke all keys of a user', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data, errors } = await mutate({ + mutation: adminRevokeUserApiKeys, + variables: { userId: 'u-regular' }, + }) + expect(errors).toBeUndefined() + expect(data.adminRevokeUserApiKeys).toBe(2) + }) + + it('returns 0 when user has no active keys', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data } = await mutate({ + mutation: adminRevokeUserApiKeys, + variables: { userId: 'u-admin' }, + }) + expect(data.adminRevokeUserApiKeys).toBe(0) + }) + }) + + describe('apiKeyUsers', () => { + it('non-admin cannot access', async () => { + authenticatedUser = (await regularUser.toJson()) as Context['user'] + const { errors } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + }) + + it('admin sees users with key stats', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data, errors } = await query({ + query: apiKeyUsers, + variables: { first: 10, offset: 0 }, + }) + expect(errors).toBeUndefined() + expect(data.apiKeyUsers.length).toBeGreaterThanOrEqual(1) + const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular') + expect(entry).toBeTruthy() + expect(entry.activeCount).toBe(1) + expect(entry.revokedCount).toBe(0) + expect(entry).toHaveProperty('postsCount') + expect(entry).toHaveProperty('commentsCount') + }) + + it('counts active and revoked keys correctly', async () => { + // Revoke one of regular's keys + authenticatedUser = (await adminUser.toJson()) as Context['user'] + await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) + const { data } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } }) + const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular') + expect(entry.activeCount).toBe(0) + expect(entry.revokedCount).toBe(1) + }) + + it('supports pagination', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data } = await query({ query: apiKeyUsers, variables: { first: 1, offset: 0 } }) + expect(data.apiKeyUsers).toHaveLength(1) + }) + }) + + describe('apiKeysForUser', () => { + it('non-admin cannot access', async () => { + authenticatedUser = (await regularUser.toJson()) as Context['user'] + const { errors } = await query({ + query: apiKeysForUser, + variables: { userId: 'u-regular' }, + }) + expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') + }) + + it('admin sees all keys for a user', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data, errors } = await query({ + query: apiKeysForUser, + variables: { userId: 'u-regular' }, + }) + expect(errors).toBeUndefined() + expect(data.apiKeysForUser).toHaveLength(1) + data.apiKeysForUser.forEach((k) => { + expect(k).toHaveProperty('id') + expect(k).toHaveProperty('name') + expect(k).toHaveProperty('keyPrefix') + expect(k).toHaveProperty('disabled') + expect(k).toHaveProperty('disabledAt') + expect(k).toHaveProperty('lastUsedAt') + expect(k).toHaveProperty('expiresAt') + }) + }) + + it('returns active keys before revoked keys', async () => { + // Create a second key so we have one active + one revoked + authenticatedUser = (await regularUser.toJson()) as Context['user'] + await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) + authenticatedUser = (await adminUser.toJson()) as Context['user'] + await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) + const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-regular' } }) + expect(data.apiKeysForUser).toHaveLength(2) + expect(data.apiKeysForUser[0].disabled).toBe(false) + expect(data.apiKeysForUser[1].disabled).toBe(true) + }) + + it('returns empty array for user without keys', async () => { + authenticatedUser = (await adminUser.toJson()) as Context['user'] + const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-admin' } }) + expect(data.apiKeysForUser).toEqual([]) + }) + }) +}) diff --git a/backend/src/graphql/resolvers/apiKeys.ts b/backend/src/graphql/resolvers/apiKeys.ts new file mode 100644 index 000000000..19e200aaa --- /dev/null +++ b/backend/src/graphql/resolvers/apiKeys.ts @@ -0,0 +1,215 @@ +import { createHash, randomBytes } from 'node:crypto' + +import { v4 as uuid } from 'uuid' + +import type { Context } from '@src/context' +import type { Integer, Record as Neo4jRecord } from 'neo4j-driver' + +interface ApiKeyArgs { + id?: string + name?: string + expiresInDays?: number + userId?: string + first?: number + offset?: number +} + +function normalizeApiKey(raw: Record) { + return { + ...raw, + lastUsedAt: raw.lastUsedAt ?? null, + expiresAt: raw.expiresAt ?? null, + disabledAt: raw.disabledAt ?? null, + } +} + +function generateApiKey(): { key: string; hash: string; prefix: string } { + const bytes = randomBytes(32) + const key = 'oak_' + bytes.toString('base64url') + const hash = createHash('sha256').update(key).digest('hex') + const prefix = key.slice(0, 12) // "oak_" + 8 chars + return { key, hash, prefix } +} + +function toNumber(value: Integer | number): number { + return typeof value === 'number' ? value : value.toNumber() +} + +function getRecord(record: Neo4jRecord, field: string): Record { + return record.get(field) as Record +} + +export default { + Query: { + myApiKeys: async (_parent: unknown, _args: unknown, context: Context) => { + const result = await context.database.query({ + query: ` + MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey) + RETURN k {.*} + ORDER BY k.createdAt DESC + `, + variables: { userId: context.user?.id }, + }) + return result.records.map((r: Neo4jRecord) => normalizeApiKey(getRecord(r, 'k'))) + }, + + apiKeyUsers: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const first = args.first ?? 50 + const offset = args.offset ?? 0 + + const result = await context.database.query({ + query: ` + MATCH (u:User)-[:HAS_API_KEY]->(k:ApiKey) + WITH u, collect(k) AS keys + WITH u, keys, + size([k IN keys WHERE NOT k.disabled]) AS activeCount, + size([k IN keys WHERE k.disabled]) AS revokedCount, + reduce(m = null, k IN keys | CASE WHEN k.lastUsedAt IS NOT NULL AND (m IS NULL OR k.lastUsedAt > m) THEN k.lastUsedAt ELSE m END) AS lastActivity + ORDER BY lastActivity IS NULL, lastActivity DESC + SKIP toInteger($offset) LIMIT toInteger($first) + WITH u, keys, activeCount, revokedCount, lastActivity, + [k IN keys | k.id] AS keyIds + OPTIONAL MATCH (p:Post) WHERE p.createdByApiKey IN keyIds + WITH u, activeCount, revokedCount, lastActivity, keyIds, count(p) AS postsCount + OPTIONAL MATCH (c:Comment) WHERE c.createdByApiKey IN keyIds + RETURN u {.*} AS user, activeCount, revokedCount, postsCount, count(c) AS commentsCount, lastActivity + `, + variables: { offset, first }, + }) + return result.records.map((r: Neo4jRecord) => ({ + user: r.get('user') as Record, + activeCount: toNumber(r.get('activeCount') as Integer), + revokedCount: toNumber(r.get('revokedCount') as Integer), + postsCount: toNumber(r.get('postsCount') as Integer), + commentsCount: toNumber(r.get('commentsCount') as Integer), + lastActivity: (r.get('lastActivity') as string | null) ?? null, + })) + }, + + apiKeysForUser: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const result = await context.database.query({ + query: ` + MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey) + RETURN k {.*} + ORDER BY k.disabled ASC, k.createdAt DESC + `, + variables: { userId: args.userId }, + }) + return result.records.map((r: Neo4jRecord) => normalizeApiKey(getRecord(r, 'k'))) + }, + }, + + Mutation: { + createApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + if (!context.config.API_KEYS_ENABLED) { + throw new Error('API keys are not enabled') + } + + if (args.expiresInDays != null && args.expiresInDays < 1) { + throw new Error('expiresInDays must be a positive integer') + } + + let expiresAt: string | null = null + if (args.expiresInDays) { + expiresAt = new Date(Date.now() + args.expiresInDays * 86400000).toISOString() + } + + const { key, hash, prefix } = generateApiKey() + const id = uuid() + + const result = await context.database.write({ + query: ` + MATCH (u:User { id: $userId }) + OPTIONAL MATCH (u)-[:HAS_API_KEY]->(existing:ApiKey) + WHERE NOT existing.disabled + WITH u, count(existing) AS activeCount + WHERE activeCount < toInteger($maxKeys) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: $id, + name: $name, + keyHash: $keyHash, + keyPrefix: $keyPrefix, + createdAt: toString(datetime()), + disabled: false + }) + ${expiresAt ? 'SET k.expiresAt = $expiresAt' : ''} + RETURN k {.*} + `, + variables: { + userId: context.user?.id, + id, + name: args.name, + keyHash: hash, + keyPrefix: prefix, + expiresAt, + maxKeys: context.config.API_KEYS_MAX_PER_USER, + }, + }) + + if (result.records.length === 0) { + throw new Error( + `Maximum of ${String(context.config.API_KEYS_MAX_PER_USER)} active API keys reached`, + ) + } + + return { + apiKey: normalizeApiKey(getRecord(result.records[0], 'k')), + secret: key, + } + }, + + updateApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const result = await context.database.write({ + query: ` + MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey { id: $keyId }) + SET k.name = $name + RETURN k {.*} + `, + variables: { userId: context.user?.id, keyId: args.id, name: args.name }, + }) + if (result.records.length === 0) { + throw new Error('API key not found') + } + return normalizeApiKey(getRecord(result.records[0], 'k')) + }, + + revokeApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const result = await context.database.write({ + query: ` + MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey { id: $keyId }) + WHERE NOT k.disabled + SET k.disabled = true, k.disabledAt = toString(datetime()) + RETURN k + `, + variables: { userId: context.user?.id, keyId: args.id }, + }) + return result.records.length > 0 + }, + + adminRevokeApiKey: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const result = await context.database.write({ + query: ` + MATCH (k:ApiKey { id: $keyId }) + WHERE NOT k.disabled + SET k.disabled = true, k.disabledAt = toString(datetime()) + RETURN k + `, + variables: { keyId: args.id }, + }) + return result.records.length > 0 + }, + + adminRevokeUserApiKeys: async (_parent: unknown, args: ApiKeyArgs, context: Context) => { + const result = await context.database.write({ + query: ` + MATCH (u:User { id: $userId })-[:HAS_API_KEY]->(k:ApiKey) + WHERE NOT k.disabled + SET k.disabled = true, k.disabledAt = toString(datetime()) + RETURN count(k) AS count + `, + variables: { userId: args.userId }, + }) + return toNumber(result.records[0].get('count') as Integer) + }, + }, +} diff --git a/backend/src/graphql/resolvers/comments.ts b/backend/src/graphql/resolvers/comments.ts index 31fd2fe3f..6ae13601c 100644 --- a/backend/src/graphql/resolvers/comments.ts +++ b/backend/src/graphql/resolvers/comments.ts @@ -30,6 +30,7 @@ export default { CREATE (comment:Comment $params) SET comment.createdAt = toString(datetime()) SET comment.updatedAt = toString(datetime()) + SET comment.createdByApiKey = $apiKeyId MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) WITH post, author, comment MERGE (post)<-[obs:OBSERVES]-(author) @@ -39,7 +40,7 @@ export default { obs.updatedAt = toString(datetime()) RETURN comment `, - { userId: user.id, postId, params }, + { userId: user.id, postId, params, apiKeyId: user.apiKeyId || null }, ) return createCommentTransactionResponse.records.map( (record) => record.get('comment').properties, diff --git a/backend/src/graphql/resolvers/posts.ts b/backend/src/graphql/resolvers/posts.ts index 0834dbd0b..5d975f3f8 100644 --- a/backend/src/graphql/resolvers/posts.ts +++ b/backend/src/graphql/resolvers/posts.ts @@ -189,6 +189,7 @@ export default { SET post.sortDate = toString(datetime()) SET post.clickedCount = 0 SET post.viewedTeaserCount = 0 + SET post.createdByApiKey = $apiKeyId SET post:${params.postType} WITH post MATCH (author:User {id: $userId}) @@ -201,7 +202,7 @@ export default { ${groupCypher} RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] } `, - { userId: user.id, categoryIds, groupId, params }, + { userId: user.id, categoryIds, groupId, params, apiKeyId: user.apiKeyId || null }, ) const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) if (imageInput) { diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index ce756691c..f79139c12 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -12,6 +12,9 @@ export default makeAugmentedSchema({ config: { query: { exclude: [ + 'ApiKey', + 'ApiKeyWithSecret', + 'ApiKeyUserSummary', 'Badge', 'Embed', 'EmailNotificationSettings', diff --git a/backend/src/graphql/types/type/ApiKey.gql b/backend/src/graphql/types/type/ApiKey.gql new file mode 100644 index 000000000..59835ec18 --- /dev/null +++ b/backend/src/graphql/types/type/ApiKey.gql @@ -0,0 +1,39 @@ +type ApiKey { + id: ID! + name: String! + keyPrefix: String! + createdAt: String! + lastUsedAt: String + expiresAt: String + disabled: Boolean! + disabledAt: String + owner: User @relation(name: "HAS_API_KEY", direction: "IN") +} + +type ApiKeyWithSecret { + apiKey: ApiKey! + secret: String! +} + +type ApiKeyUserSummary { + user: User! + activeCount: Int! + revokedCount: Int! + postsCount: Int! + commentsCount: Int! + lastActivity: String +} + +type Query { + myApiKeys: [ApiKey!]! + apiKeyUsers(first: Int, offset: Int): [ApiKeyUserSummary!]! + apiKeysForUser(userId: ID!): [ApiKey!]! +} + +type Mutation { + createApiKey(name: String!, expiresInDays: Int): ApiKeyWithSecret! + updateApiKey(id: ID!, name: String!): ApiKey! + revokeApiKey(id: ID!): Boolean! + adminRevokeApiKey(id: ID!): Boolean! + adminRevokeUserApiKeys(userId: ID!): Int! +} diff --git a/backend/src/jwt/decode.spec.ts b/backend/src/jwt/decode.spec.ts index 3bcaf75fa..7a8478639 100644 --- a/backend/src/jwt/decode.spec.ts +++ b/backend/src/jwt/decode.spec.ts @@ -42,6 +42,200 @@ describe('decode', () => { await expect(decode(context)(authorizationHeader)).resolves.toBeNull() } + describe('given API key with oak_ prefix', () => { + describe('and valid API key in database', () => { + beforeEach(async () => { + await Factory.build('user', { + id: 'api-user', + name: 'API User', + slug: 'api-user', + role: 'user', + }) + // Create API key node with known hash + // SHA-256 of 'oak_testkey123' = known hash + const crypto = await import('node:crypto') + const keyHash = crypto.createHash('sha256').update('oak_testkey123').digest('hex') + const session = driver.session() + await session.writeTransaction(async (tx) => { + await tx.run( + `MATCH (u:User { id: $userId }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak1', + name: 'Test Key', + keyHash: $keyHash, + keyPrefix: 'oak_testke', + createdAt: toString(datetime()), + disabled: false + })`, + { userId: 'api-user', keyHash }, + ) + }) + await session.close() + }) + + it('returns user object with authMethod apiKey', async () => { + await expect(decode(context)('Bearer oak_testkey123')).resolves.toMatchObject({ + id: 'api-user', + name: 'API User', + authMethod: 'apiKey', + apiKeyId: 'ak1', + }) + }) + + it('updates lastUsedAt on the API key', async () => { + await decode(context)('Bearer oak_testkey123') + // Give fire-and-forget time to complete + const { setTimeout: delay } = await import('node:timers/promises') + await delay(500) + const session = driver.session() + const result = await session.readTransaction(async (tx) => { + return tx.run(`MATCH (k:ApiKey { id: 'ak1' }) RETURN k.lastUsedAt AS lastUsedAt`) + }) + await session.close() + expect(result.records[0].get('lastUsedAt')).toBeTruthy() + }) + }) + + describe('and disabled API key', () => { + beforeEach(async () => { + await Factory.build('user', { + id: 'disabled-key-user', + name: 'DK User', + slug: 'dk-user', + role: 'user', + }) + const crypto = await import('node:crypto') + const keyHash = crypto.createHash('sha256').update('oak_disabledkey').digest('hex') + const session = driver.session() + await session.writeTransaction(async (tx) => { + await tx.run( + `MATCH (u:User { id: $userId }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-disabled', + name: 'Disabled Key', + keyHash: $keyHash, + keyPrefix: 'oak_disabl', + createdAt: toString(datetime()), + disabled: true + })`, + { userId: 'disabled-key-user', keyHash }, + ) + }) + await session.close() + authorizationHeader = 'Bearer oak_disabledkey' + }) + + it('returns null', returnsNull) + }) + + describe('and expired API key', () => { + beforeEach(async () => { + await Factory.build('user', { + id: 'expired-key-user', + name: 'EK User', + slug: 'ek-user', + role: 'user', + }) + const crypto = await import('node:crypto') + const keyHash = crypto.createHash('sha256').update('oak_expiredkey').digest('hex') + const session = driver.session() + await session.writeTransaction(async (tx) => { + await tx.run( + `MATCH (u:User { id: $userId }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-expired', + name: 'Expired Key', + keyHash: $keyHash, + keyPrefix: 'oak_expire', + createdAt: toString(datetime()), + expiresAt: '2020-01-01T00:00:00.000Z', + disabled: false + })`, + { userId: 'expired-key-user', keyHash }, + ) + }) + await session.close() + authorizationHeader = 'Bearer oak_expiredkey' + }) + + it('returns null', returnsNull) + }) + + describe('and nonexistent API key', () => { + beforeEach(() => { + authorizationHeader = 'Bearer oak_doesnotexist' + }) + + it('returns null', returnsNull) + }) + + describe('and API key belonging to disabled user', () => { + beforeEach(async () => { + await Factory.build('user', { + id: 'disabled-user', + name: 'Disabled User', + slug: 'disabled-user', + role: 'user', + disabled: true, + }) + const crypto = await import('node:crypto') + const keyHash = crypto.createHash('sha256').update('oak_disableduser').digest('hex') + const session = driver.session() + await session.writeTransaction(async (tx) => { + await tx.run( + `MATCH (u:User { id: $userId }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-disabled-user', + name: 'Key of Disabled User', + keyHash: $keyHash, + keyPrefix: 'oak_disabl', + createdAt: toString(datetime()), + disabled: false + })`, + { userId: 'disabled-user', keyHash }, + ) + }) + await session.close() + authorizationHeader = 'Bearer oak_disableduser' + }) + + it('returns null', returnsNull) + }) + + describe('and API key belonging to deleted user', () => { + beforeEach(async () => { + await Factory.build('user', { + id: 'deleted-user', + name: 'Deleted User', + slug: 'deleted-user', + role: 'user', + deleted: true, + }) + const crypto = await import('node:crypto') + const keyHash = crypto.createHash('sha256').update('oak_deleteduser').digest('hex') + const session = driver.session() + await session.writeTransaction(async (tx) => { + await tx.run( + `MATCH (u:User { id: $userId }) + CREATE (u)-[:HAS_API_KEY]->(k:ApiKey { + id: 'ak-deleted-user', + name: 'Key of Deleted User', + keyHash: $keyHash, + keyPrefix: 'oak_delete', + createdAt: toString(datetime()), + disabled: false + })`, + { userId: 'deleted-user', keyHash }, + ) + }) + await session.close() + authorizationHeader = 'Bearer oak_deleteduser' + }) + + it('returns null', returnsNull) + }) + }) + describe('given `null` as JWT Bearer token', () => { beforeEach(() => { authorizationHeader = null diff --git a/backend/src/jwt/decode.ts b/backend/src/jwt/decode.ts index 78a045015..b2e4f1050 100644 --- a/backend/src/jwt/decode.ts +++ b/backend/src/jwt/decode.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ +import { createHash } from 'node:crypto' + import { verify } from 'jsonwebtoken' import type CONFIG from '@src/config' @@ -12,44 +14,106 @@ export interface DecodedUser { name: string role: string disabled: boolean + authMethod?: 'jwt' | 'apiKey' + apiKeyId?: string } const jwt = { verify } + +const decodeJwt = async ( + context: { config: Pick; driver: Driver }, + token: string, +): Promise => { + let id: null | string = null + try { + const decoded = jwt.verify(token, context.config.JWT_SECRET) as JwtPayload + id = decoded.sub ?? null + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return null + } + const session = context.driver.session() + + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const updateUserLastActiveTransactionResponse = await transaction.run( + ` + MATCH (user:User {id: $id, deleted: false, disabled: false }) + SET user.lastActiveAt = toString(datetime()) + RETURN user {.id, .slug, .name, .role, .disabled, .actorId} + LIMIT 1 + `, + { id }, + ) + return updateUserLastActiveTransactionResponse.records.map((record) => record.get('user')) + }) + try { + const [currentUser] = await writeTxResultPromise + if (!currentUser) return null + return { + ...currentUser, + authMethod: 'jwt' as const, + } + } finally { + await session.close() + } +} + +const decodeApiKey = async (driver: Driver, key: string): Promise => { + const keyHash = createHash('sha256').update(key).digest('hex') + const session = driver.session() + try { + const result = await session.readTransaction(async (transaction) => { + return transaction.run( + ` + MATCH (user:User)-[:HAS_API_KEY]->(k:ApiKey { keyHash: $keyHash }) + WHERE k.disabled = false + AND (k.expiresAt IS NULL OR datetime(k.expiresAt) > datetime()) + AND user.deleted = false + AND user.disabled = false + RETURN user {.id, .slug, .name, .role, .disabled, .actorId} AS user, k.id AS keyId + LIMIT 1 + `, + { keyHash }, + ) + }) + if (result.records.length === 0) return null + + const record = result.records[0] + const user = record.get('user') as DecodedUser + const keyId = record.get('keyId') as string + + // Update lastUsedAt asynchronously (non-blocking, separate session) + const updateSession = driver.session() + void updateSession + .writeTransaction(async (transaction) => { + await transaction.run( + `MATCH (k:ApiKey { id: $keyId }) SET k.lastUsedAt = toString(datetime())`, + { keyId }, + ) + }) + .catch(() => {}) + .finally(async () => updateSession.close()) + + return { + ...user, + authMethod: 'apiKey' as const, + apiKeyId: keyId, + } + } finally { + await session.close() + } +} + export const decode = (context: { config: Pick; driver: Driver }) => async (authorizationHeader: string | undefined | null) => { if (!authorizationHeader) return null const token = authorizationHeader.replace('Bearer ', '') - let id: null | string = null - try { - const decoded = jwt.verify(token, context.config.JWT_SECRET) as JwtPayload - id = decoded.sub ?? null - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - return null - } - const session = context.driver.session() - const writeTxResultPromise = session.writeTransaction(async (transaction) => { - const updateUserLastActiveTransactionResponse = await transaction.run( - ` - MATCH (user:User {id: $id, deleted: false, disabled: false }) - SET user.lastActiveAt = toString(datetime()) - RETURN user {.id, .slug, .name, .role, .disabled, .actorId} - LIMIT 1 - `, - { id }, - ) - return updateUserLastActiveTransactionResponse.records.map((record) => record.get('user')) - }) - try { - const [currentUser] = await writeTxResultPromise - if (!currentUser) return null - return { - token, - ...currentUser, - } - } finally { - await session.close() + // Route by token prefix: oak_ = API key, otherwise JWT + if (token.startsWith('oak_')) { + return decodeApiKey(context.driver, token) } + + return decodeJwt(context, token) } diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index f34649375..253fdb6e9 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -34,6 +34,14 @@ const isAdmin = rule()(async (_parent, _args, { user }: Context, _info) => { return !!(user?.role === 'admin') }) +const apiKeysEnabled = rule({ cache: 'contextual' })(async ( + _parent, + _args, + { config }: Context, +) => { + return config.API_KEYS_ENABLED +}) + const onlyYourself = rule({ cache: 'no_cache', })(async (_parent, args, context: Context, _info) => { @@ -462,6 +470,11 @@ export default shield( // Invite Code validateInviteCode: allow, + + // API Keys + myApiKeys: and(isAuthenticated, apiKeysEnabled), + apiKeyUsers: isAdmin, + apiKeysForUser: isAdmin, }, Mutation: { '*': deny, @@ -521,6 +534,13 @@ export default shield( invalidateInviteCode: isAuthenticated, redeemInviteCode: isAuthenticated, + // API Keys + createApiKey: and(isAuthenticated, apiKeysEnabled), + updateApiKey: isAuthenticated, + revokeApiKey: isAuthenticated, + adminRevokeApiKey: isAdmin, + adminRevokeUserApiKeys: isAdmin, + switchUserRole: isAdmin, markTeaserAsViewed: allow, saveCategorySettings: isAuthenticated, diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index fc718b394..106f7e7ee 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -59,6 +59,8 @@ export const TEST_CONFIG = { CATEGORIES_ACTIVE: false, MAX_PINNED_POSTS: 1, MAX_GROUP_PINNED_POSTS: 1, + API_KEYS_ENABLED: false, + API_KEYS_MAX_PER_USER: 5, LANGUAGE_DEFAULT: 'en', LOG_LEVEL: 'DEBUG', diff --git a/cypress/e2e/settings/ApiKey.feature b/cypress/e2e/settings/ApiKey.feature new file mode 100644 index 000000000..e1bdb5f88 --- /dev/null +++ b/cypress/e2e/settings/ApiKey.feature @@ -0,0 +1,29 @@ +Feature: API Key Authentication + As a User + I'd like to create and use API keys + So I can access the API without my password + + Background: + Given the following "users" are in the database: + | email | password | id | name | slug | role | termsAndConditionsAgreedVersion | + | peterpan@example.org | 123 | id-of-peter-pan | Peter Pan | peter-pan | user | 0.0.4 | + + Scenario: Create an API key and use it to query the API + Given I am logged in as "peter-pan" + When I navigate to page "/settings/api-keys" + Then I am on page "/settings/api-keys" + When I create an API key with name "E2E Test Key" + Then I see a toaster with status "success" + And I see the API key secret + When I use the API key to query currentUser + Then the API returns my user name "Peter Pan" + + Scenario: Revoked API key is rejected + Given I am logged in as "peter-pan" + When I navigate to page "/settings/api-keys" + And I create an API key with name "To Revoke" + And I revoke the first API key + And I confirm the action in the modal + Then I see a toaster with status "success" + When I use the revoked API key to query currentUser + Then the API returns an authentication error diff --git a/cypress/support/step_definitions/settings/ApiKey/I_confirm_the_action_in_the_modal.js b/cypress/support/step_definitions/settings/ApiKey/I_confirm_the_action_in_the_modal.js new file mode 100644 index 000000000..0d8718170 --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_confirm_the_action_in_the_modal.js @@ -0,0 +1,6 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('I confirm the action in the modal', () => { + cy.get('[data-test="confirm-modal"]').should('be.visible') + cy.get('[data-test="confirm-button"]').click() +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/I_create_an_API_key_with_name_{string}.js b/cypress/support/step_definitions/settings/ApiKey/I_create_an_API_key_with_name_{string}.js new file mode 100644 index 000000000..cf6cc87c4 --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_create_an_API_key_with_name_{string}.js @@ -0,0 +1,6 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('I create an API key with name {string}', (name) => { + cy.get('#api-key-name').clear().type(name) + cy.get('#api-key-name').closest('form').submit() +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/I_revoke_the_first_API_key.js b/cypress/support/step_definitions/settings/ApiKey/I_revoke_the_first_API_key.js new file mode 100644 index 000000000..a11605bbd --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_revoke_the_first_API_key.js @@ -0,0 +1,9 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('I revoke the first API key', () => { + cy.get('[data-test="revoke-api-key"]') + .first() + .scrollIntoView() + .should('be.visible') + .click() +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/I_see_the_API_key_secret.js b/cypress/support/step_definitions/settings/ApiKey/I_see_the_API_key_secret.js new file mode 100644 index 000000000..6a0672ce3 --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_see_the_API_key_secret.js @@ -0,0 +1,9 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('I see the API key secret', () => { + cy.get('.secret-code').should('be.visible').invoke('text').then((secret) => { + const trimmed = secret.trim() + expect(trimmed).to.match(/^oak_/) + cy.task('pushValue', { name: 'apiKeySecret', value: trimmed }) + }) +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/I_use_the_API_key_to_query_currentUser.js b/cypress/support/step_definitions/settings/ApiKey/I_use_the_API_key_to_query_currentUser.js new file mode 100644 index 000000000..8fe16f23b --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_use_the_API_key_to_query_currentUser.js @@ -0,0 +1,14 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' +import { GraphQLClient } from 'graphql-request' + +defineStep('I use the API key to query currentUser', () => { + cy.task('getValue', 'apiKeySecret').then((secret) => { + const client = new GraphQLClient(Cypress.env('GRAPHQL_URI'), { + headers: { authorization: `Bearer ${secret}` }, + }) + const query = `query { currentUser { id name } }` + cy.wrap(client.request(query)).then((response) => { + cy.task('pushValue', { name: 'apiKeyResponse', value: response }) + }) + }) +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/I_use_the_revoked_API_key_to_query_currentUser.js b/cypress/support/step_definitions/settings/ApiKey/I_use_the_revoked_API_key_to_query_currentUser.js new file mode 100644 index 000000000..26d0e4c7a --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/I_use_the_revoked_API_key_to_query_currentUser.js @@ -0,0 +1,16 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('I use the revoked API key to query currentUser', () => { + cy.task('getValue', 'apiKeySecret').then((secret) => { + expect(secret).to.be.a('string').and.match(/^oak_/) + cy.request({ + method: 'POST', + url: Cypress.env('GRAPHQL_URI'), + headers: { authorization: `Bearer ${secret}`, 'content-type': 'application/json' }, + body: { query: '{ currentUser { id name } }' }, + failOnStatusCode: false, + }).then((response) => { + cy.task('pushValue', { name: 'revokedApiKeyResponse', value: response.body }) + }) + }) +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/the_API_returns_an_authentication_error.js b/cypress/support/step_definitions/settings/ApiKey/the_API_returns_an_authentication_error.js new file mode 100644 index 000000000..fc750f323 --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/the_API_returns_an_authentication_error.js @@ -0,0 +1,11 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('the API returns an authentication error', () => { + cy.task('getValue', 'revokedApiKeyResponse').then((response) => { + // currentUser should be null (unauthenticated) or return an error + const hasError = + (response.errors && response.errors.length > 0) || + (response.data && response.data.currentUser === null) + expect(hasError).to.be.true + }) +}) diff --git a/cypress/support/step_definitions/settings/ApiKey/the_API_returns_my_user_name_{string}.js b/cypress/support/step_definitions/settings/ApiKey/the_API_returns_my_user_name_{string}.js new file mode 100644 index 000000000..24e32bc87 --- /dev/null +++ b/cypress/support/step_definitions/settings/ApiKey/the_API_returns_my_user_name_{string}.js @@ -0,0 +1,7 @@ +import { defineStep } from '@badeball/cypress-cucumber-preprocessor' + +defineStep('the API returns my user name {string}', (expectedName) => { + cy.task('getValue', 'apiKeyResponse').then((response) => { + expect(response.currentUser.name).to.equal(expectedName) + }) +}) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1bea14b22..b409ab9ed 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -7,6 +7,8 @@ services: target: test environment: - NODE_ENV=test + - API_KEYS_ENABLED=true + - API_KEYS_MAX_PER_USER=5 volumes: - ./coverage:/app/coverage diff --git a/webapp/.env.template b/webapp/.env.template index 1816d9df2..f333c200d 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -12,4 +12,6 @@ NETWORK_NAME="Ocelot.social" ASK_FOR_REAL_NAME=false REQUIRE_LOCATION=false -MAX_GROUP_PINNED_POSTS=1 \ No newline at end of file +MAX_GROUP_PINNED_POSTS=1 +API_KEYS_ENABLED=false +API_KEYS_MAX_PER_USER=5 \ No newline at end of file diff --git a/webapp/config/index.js b/webapp/config/index.js index 49835fda1..a81195e66 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -61,6 +61,8 @@ const options = { MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS)) ? 1 : Number(process.env.MAX_GROUP_PINNED_POSTS), + API_KEYS_ENABLED: process.env.API_KEYS_ENABLED === 'true' || false, + API_KEYS_MAX_PER_USER: Number(process.env.API_KEYS_MAX_PER_USER) || 5, } const language = { diff --git a/webapp/graphql/admin/ApiKeys.js b/webapp/graphql/admin/ApiKeys.js new file mode 100644 index 000000000..eae651c45 --- /dev/null +++ b/webapp/graphql/admin/ApiKeys.js @@ -0,0 +1,59 @@ +import gql from 'graphql-tag' +import { imageUrls } from '../fragments/imageUrls' + +export const apiKeyUsersQuery = () => { + return gql` + ${imageUrls} + + query ($first: Int, $offset: Int) { + apiKeyUsers(first: $first, offset: $offset) { + user { + id + name + slug + avatar { + ...imageUrls + } + } + activeCount + revokedCount + postsCount + commentsCount + lastActivity + } + } + ` +} + +export const apiKeysForUserQuery = () => { + return gql` + query ($userId: ID!) { + apiKeysForUser(userId: $userId) { + id + name + keyPrefix + createdAt + lastUsedAt + expiresAt + disabled + disabledAt + } + } + ` +} + +export const adminRevokeApiKeyMutation = () => { + return gql` + mutation ($id: ID!) { + adminRevokeApiKey(id: $id) + } + ` +} + +export const adminRevokeUserApiKeysMutation = () => { + return gql` + mutation ($userId: ID!) { + adminRevokeUserApiKeys(userId: $userId) + } + ` +} diff --git a/webapp/graphql/settings/ApiKeys.js b/webapp/graphql/settings/ApiKeys.js new file mode 100644 index 000000000..b2f1c946c --- /dev/null +++ b/webapp/graphql/settings/ApiKeys.js @@ -0,0 +1,55 @@ +import gql from 'graphql-tag' + +export const myApiKeysQuery = () => { + return gql` + query { + myApiKeys { + id + name + keyPrefix + createdAt + lastUsedAt + expiresAt + disabled + disabledAt + } + } + ` +} + +export const createApiKeyMutation = () => { + return gql` + mutation ($name: String!, $expiresInDays: Int) { + createApiKey(name: $name, expiresInDays: $expiresInDays) { + apiKey { + id + name + keyPrefix + createdAt + expiresAt + disabled + } + secret + } + } + ` +} + +export const updateApiKeyMutation = () => { + return gql` + mutation ($id: ID!, $name: String!) { + updateApiKey(id: $id, name: $name) { + id + name + } + } + ` +} + +export const revokeApiKeyMutation = () => { + return gql` + mutation ($id: ID!) { + revokeApiKey(id: $id) + } + ` +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 09875ac82..b630562a8 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -14,6 +14,48 @@ "search": "Suchen" }, "admin": { + "api-keys": { + "detail": { + "active": "Aktive Keys ({count})", + "revoked": "Widerrufene Keys ({count})" + }, + "empty": "Keine Nutzer mit API Keys gefunden.", + "name": "API Keys", + "never": "nie", + "order": { + "active-keys": "Aktive Keys", + "comments": "Anzahl Kommentare", + "last-used": "Letzte Aktivität", + "posts": "Anzahl Beiträge" + }, + "revoke": { + "confirm": "Widerrufen", + "message": "Möchtest du den API Key \"{name}\" von {user} wirklich widerrufen?", + "success": "API Key wurde widerrufen", + "title": "API Key widerrufen" + }, + "revoke-all": "Alle Keys dieses Nutzers widerrufen", + "revoke-all-confirm": "Alle widerrufen", + "revoke-all-message": "Möchtest du wirklich ALLE API Keys von {user} widerrufen? Diese Aktion kann nicht rückgängig gemacht werden.", + "revoke-all-short": "Alle widerrufen", + "revoke-all-success": "{count} API Keys von {user} wurden widerrufen", + "revoke-all-title": "Alle API Keys widerrufen", + "revoke-key": "Key widerrufen", + "revoked-at": "Widerrufen am", + "show-keys": "Keys anzeigen", + "sort-by": "Sortieren nach", + "table": { + "actions": "Aktionen", + "active": "Aktiv", + "comments": "Kommentare", + "last-activity": "Letzte Aktivität", + "name": "Name", + "posts": "Beiträge", + "prefix": "Key", + "revoked-count": "Widerrufen", + "user": "Nutzer" + } + }, "badges": { "description": "Stelle die verfügbaren Auszeichnungen für diesen Nutzer ein.", "noBadges": "Keine Auszeichnungen vorhanden.", @@ -1064,6 +1106,51 @@ "title": "Suchergebnisse" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} Tage", + "expiry-label": "Gültigkeit", + "expiry-never": "Kein Ablaufdatum", + "limit-reached": "Du hast das Maximum von {max} aktiven API Keys erreicht. Widerrufe einen bestehenden Key, um einen neuen zu erstellen.", + "name-label": "Name", + "submit": "Key erstellen", + "success": "API Key wurde erstellt", + "title": "Neuen API Key erstellen" + }, + "list": { + "actions": "Aktionen", + "created-at": "Erstellt am", + "empty": "Du hast noch keine API Keys.", + "expired": "abgelaufen", + "expires": "Läuft ab", + "last-used": "Letzte Nutzung", + "name": "Name", + "never": "nie", + "never-expires": "nie", + "prefix": "Key", + "revoke": "Key widerrufen", + "revoked": "widerrufen", + "title": "Meine API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Widerrufen", + "message": "Möchtest du den API Key \"{name}\" wirklich widerrufen? Diese Aktion kann nicht rückgängig gemacht werden.", + "success": "API Key \"{name}\" wurde widerrufen", + "title": "API Key widerrufen" + }, + "revoked-list": { + "revoked-at": "Widerrufen am", + "title": "Widerrufene Keys ({count})" + }, + "secret": { + "copied": "API Key in die Zwischenablage kopiert", + "copy": "Kopieren", + "copy-failed": "Kopieren fehlgeschlagen", + "title": "Dein neuer API Key (nur einmal sichtbar!)", + "warning": "Speichere diesen Key jetzt. Du kannst ihn nach dem Schließen dieser Seite nicht mehr einsehen." + } + }, "badges": { "click-to-select": "Klicke auf einen freien Platz, um eine Badge hinzufügen.", "click-to-use": "Klicke auf eine Badge, um sie zu platzieren.", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 02224468a..cbd9fa4ca 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -14,6 +14,48 @@ "search": "Search" }, "admin": { + "api-keys": { + "detail": { + "active": "Active keys ({count})", + "revoked": "Revoked keys ({count})" + }, + "empty": "No users with API keys found.", + "name": "API Keys", + "never": "never", + "order": { + "active-keys": "Active keys", + "comments": "Comments count", + "last-used": "Last activity", + "posts": "Posts count" + }, + "revoke": { + "confirm": "Revoke", + "message": "Are you sure you want to revoke the API key \"{name}\" of {user}?", + "success": "API key revoked successfully", + "title": "Revoke API Key" + }, + "revoke-all": "Revoke all keys of this user", + "revoke-all-confirm": "Revoke all", + "revoke-all-message": "Are you sure you want to revoke ALL API keys of {user}? This action cannot be undone.", + "revoke-all-short": "Revoke all", + "revoke-all-success": "{count} API keys of {user} have been revoked", + "revoke-all-title": "Revoke all API Keys", + "revoke-key": "Revoke key", + "revoked-at": "Revoked at", + "show-keys": "Show keys", + "sort-by": "Sort by", + "table": { + "actions": "Actions", + "active": "Active", + "comments": "Comments", + "last-activity": "Last activity", + "name": "Name", + "posts": "Posts", + "prefix": "Key", + "revoked-count": "Revoked", + "user": "User" + } + }, "badges": { "description": "Configure the available badges for this user", "noBadges": "There are no badges available", @@ -1064,6 +1106,51 @@ "title": "Search Results" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} days", + "expiry-label": "Expiry", + "expiry-never": "No expiry", + "limit-reached": "You have reached the maximum of {max} active API keys. Revoke an existing key to create a new one.", + "name-label": "Name", + "submit": "Create key", + "success": "API key created successfully", + "title": "Create new API Key" + }, + "list": { + "actions": "Actions", + "created-at": "Created at", + "empty": "You have no API keys yet.", + "expired": "expired", + "expires": "Expires", + "last-used": "Last used", + "name": "Name", + "never": "never", + "never-expires": "never", + "prefix": "Key", + "revoke": "Revoke key", + "revoked": "revoked", + "title": "My API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Revoke", + "message": "Are you sure you want to revoke the API key \"{name}\"? This action cannot be undone.", + "success": "API key \"{name}\" has been revoked", + "title": "Revoke API Key" + }, + "revoked-list": { + "revoked-at": "Revoked at", + "title": "Revoked keys ({count})" + }, + "secret": { + "copied": "API key copied to clipboard", + "copy": "Copy", + "copy-failed": "Failed to copy to clipboard", + "title": "Your new API Key (visible only once!)", + "warning": "Save this key now. You will not be able to see it again after closing this page." + } + }, "badges": { "click-to-select": "Click on an empty space to add a badge.", "click-to-use": "Click on a badge to use it in the selected slot.", diff --git a/webapp/locales/es.json b/webapp/locales/es.json index 62932aea6..5dab01c88 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -14,6 +14,48 @@ "search": "Buscar" }, "admin": { + "api-keys": { + "detail": { + "active": "Keys activas ({count})", + "revoked": "Keys revocadas ({count})" + }, + "empty": "No se encontraron usuarios con API Keys.", + "name": "API Keys", + "never": "nunca", + "order": { + "active-keys": "Keys activas", + "comments": "Número de comentarios", + "last-used": "Última actividad", + "posts": "Número de publicaciones" + }, + "revoke": { + "confirm": "Revocar", + "message": "¿Estás seguro de que quieres revocar la API Key \"{name}\" de {user}?", + "success": "API Key revocada con éxito", + "title": "Revocar API Key" + }, + "revoke-all": "Revocar todas las keys de este usuario", + "revoke-all-confirm": "Revocar todas", + "revoke-all-message": "¿Estás seguro de que quieres revocar TODAS las API Keys de {user}? Esta acción no se puede deshacer.", + "revoke-all-short": "Revocar todas", + "revoke-all-success": "{count} API Keys de {user} han sido revocadas", + "revoke-all-title": "Revocar todas las API Keys", + "revoke-key": "Revocar key", + "revoked-at": "Revocada el", + "show-keys": "Mostrar keys", + "sort-by": "Ordenar por", + "table": { + "actions": "Acciones", + "active": "Activas", + "comments": "Comentarios", + "last-activity": "Última actividad", + "name": "Nombre", + "posts": "Publicaciones", + "prefix": "Key", + "revoked-count": "Revocadas", + "user": "Usuario" + } + }, "badges": { "description": "Configurar las insignias disponibles para este usuario", "noBadges": "No hay insignias disponibles", @@ -1064,6 +1106,51 @@ "title": "Resultados de búsqueda" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} días", + "expiry-label": "Validez", + "expiry-never": "Sin fecha de expiración", + "limit-reached": "Has alcanzado el máximo de {max} API Keys activas. Revoca una key existente para crear una nueva.", + "name-label": "Nombre", + "submit": "Crear key", + "success": "API Key creada con éxito", + "title": "Crear nueva API Key" + }, + "list": { + "actions": "Acciones", + "created-at": "Creada el", + "empty": "Aún no tienes API Keys.", + "expired": "expirada", + "expires": "Expira", + "last-used": "Último uso", + "name": "Nombre", + "never": "nunca", + "never-expires": "nunca", + "prefix": "Key", + "revoke": "Revocar key", + "revoked": "revocada", + "title": "Mis API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Revocar", + "message": "¿Estás seguro de que quieres revocar la API Key \"{name}\"? Esta acción no se puede deshacer.", + "success": "API Key \"{name}\" ha sido revocada", + "title": "Revocar API Key" + }, + "revoked-list": { + "revoked-at": "Revocada el", + "title": "Keys revocadas ({count})" + }, + "secret": { + "copied": "API Key copiada al portapapeles", + "copy": "Copiar", + "copy-failed": "Error al copiar al portapapeles", + "title": "Tu nueva API Key (¡visible solo una vez!)", + "warning": "Guarda esta key ahora. No podrás verla de nuevo después de cerrar esta página." + } + }, "badges": { "click-to-select": "Haz clic en un espacio vacío para añadir una insignia.", "click-to-use": "Haz clic en una insignia para colocarla en el espacio seleccionado.", diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index 42bf16bb1..245fc8ca3 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -14,6 +14,48 @@ "search": "Rechercher" }, "admin": { + "api-keys": { + "detail": { + "active": "Keys actives ({count})", + "revoked": "Keys révoquées ({count})" + }, + "empty": "Aucun utilisateur avec des API Keys trouvé.", + "name": "API Keys", + "never": "jamais", + "order": { + "active-keys": "Keys actives", + "comments": "Nombre de commentaires", + "last-used": "Dernière activité", + "posts": "Nombre de publications" + }, + "revoke": { + "confirm": "Révoquer", + "message": "Es-tu sûr de vouloir révoquer l'API Key \"{name}\" de {user} ?", + "success": "API Key révoquée avec succès", + "title": "Révoquer l'API Key" + }, + "revoke-all": "Révoquer toutes les keys de cet utilisateur", + "revoke-all-confirm": "Tout révoquer", + "revoke-all-message": "Es-tu sûr de vouloir révoquer TOUTES les API Keys de {user} ? Cette action est irréversible.", + "revoke-all-short": "Tout révoquer", + "revoke-all-success": "{count} API Keys de {user} ont été révoquées", + "revoke-all-title": "Révoquer toutes les API Keys", + "revoke-key": "Révoquer la key", + "revoked-at": "Révoquée le", + "show-keys": "Afficher les keys", + "sort-by": "Trier par", + "table": { + "actions": "Actions", + "active": "Actives", + "comments": "Commentaires", + "last-activity": "Dernière activité", + "name": "Nom", + "posts": "Publications", + "prefix": "Key", + "revoked-count": "Révoquées", + "user": "Utilisateur" + } + }, "badges": { "description": "Configurer les badges disponibles pour cet utilisateur", "noBadges": "Aucun badge disponible", @@ -1064,6 +1106,51 @@ "title": "Résultats de recherche" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} jours", + "expiry-label": "Validité", + "expiry-never": "Sans date d'expiration", + "limit-reached": "Tu as atteint le maximum de {max} API Keys actives. Révoque une key existante pour en créer une nouvelle.", + "name-label": "Nom", + "submit": "Créer la key", + "success": "API Key créée avec succès", + "title": "Créer un nouveau API Key" + }, + "list": { + "actions": "Actions", + "created-at": "Créée le", + "empty": "Tu n'as pas encore d'API Keys.", + "expired": "expirée", + "expires": "Expire", + "last-used": "Dernière utilisation", + "name": "Nom", + "never": "jamais", + "never-expires": "jamais", + "prefix": "Key", + "revoke": "Révoquer la key", + "revoked": "révoquée", + "title": "Mes API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Révoquer", + "message": "Es-tu sûr de vouloir révoquer l'API Key \"{name}\" ? Cette action est irréversible.", + "success": "L'API Key \"{name}\" a été révoquée", + "title": "Révoquer l'API Key" + }, + "revoked-list": { + "revoked-at": "Révoquée le", + "title": "Keys révoquées ({count})" + }, + "secret": { + "copied": "API Key copiée dans le presse-papiers", + "copy": "Copier", + "copy-failed": "Échec de la copie dans le presse-papiers", + "title": "Ton nouveau API Key (visible une seule fois !)", + "warning": "Enregistre cette key maintenant. Tu ne pourras plus la voir après avoir fermé cette page." + } + }, "badges": { "click-to-select": "Cliquez sur un emplacement vide pour ajouter un badge.", "click-to-use": "Cliquez sur un badge pour le placer dans l'emplacement sélectionné.", diff --git a/webapp/locales/it.json b/webapp/locales/it.json index f95eb8df2..0c620fb7d 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -14,6 +14,48 @@ "search": "Cerca" }, "admin": { + "api-keys": { + "detail": { + "active": "Keys attive ({count})", + "revoked": "Keys revocate ({count})" + }, + "empty": "Nessun utente con API Keys trovato.", + "name": "API Keys", + "never": "mai", + "order": { + "active-keys": "Keys attive", + "comments": "Numero di commenti", + "last-used": "Ultima attività", + "posts": "Numero di post" + }, + "revoke": { + "confirm": "Revoca", + "message": "Sei sicuro di voler revocare l'API Key \"{name}\" di {user}?", + "success": "API Key revocata con successo", + "title": "Revoca API Key" + }, + "revoke-all": "Revoca tutte le keys di questo utente", + "revoke-all-confirm": "Revoca tutte", + "revoke-all-message": "Sei sicuro di voler revocare TUTTE le API Keys di {user}? Questa azione non può essere annullata.", + "revoke-all-short": "Revoca tutte", + "revoke-all-success": "{count} API Keys di {user} sono state revocate", + "revoke-all-title": "Revoca tutte le API Keys", + "revoke-key": "Revoca key", + "revoked-at": "Revocata il", + "show-keys": "Mostra keys", + "sort-by": "Ordina per", + "table": { + "actions": "Azioni", + "active": "Attive", + "comments": "Commenti", + "last-activity": "Ultima attività", + "name": "Nome", + "posts": "Post", + "prefix": "Key", + "revoked-count": "Revocate", + "user": "Utente" + } + }, "badges": { "description": "Configura i badge disponibili per questo utente", "noBadges": "Non ci sono badge disponibili", @@ -1064,6 +1106,51 @@ "title": "Risultati della ricerca" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} giorni", + "expiry-label": "Validità", + "expiry-never": "Nessuna scadenza", + "limit-reached": "Hai raggiunto il massimo di {max} API Keys attive. Revoca una key esistente per crearne una nuova.", + "name-label": "Nome", + "submit": "Crea key", + "success": "API Key creata con successo", + "title": "Crea nuovo API Key" + }, + "list": { + "actions": "Azioni", + "created-at": "Creata il", + "empty": "Non hai ancora API Keys.", + "expired": "scaduta", + "expires": "Scade", + "last-used": "Ultimo utilizzo", + "name": "Nome", + "never": "mai", + "never-expires": "mai", + "prefix": "Key", + "revoke": "Revoca key", + "revoked": "revocata", + "title": "Le mie API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Revoca", + "message": "Sei sicuro di voler revocare l'API Key \"{name}\"? Questa azione non può essere annullata.", + "success": "L'API Key \"{name}\" è stata revocata", + "title": "Revoca API Key" + }, + "revoked-list": { + "revoked-at": "Revocata il", + "title": "Keys revocate ({count})" + }, + "secret": { + "copied": "API Key copiata negli appunti", + "copy": "Copia", + "copy-failed": "Copia negli appunti non riuscita", + "title": "Il tuo nuovo API Key (visibile solo una volta!)", + "warning": "Salva questa key ora. Non potrai vederla di nuovo dopo aver chiuso questa pagina." + } + }, "badges": { "click-to-select": "Clicca su uno spazio vuoto per aggiungere un badge.", "click-to-use": "Clicca su un badge per posizionarlo nello spazio selezionato.", diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index 276675294..f89e66509 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -14,6 +14,48 @@ "search": "Zoeken" }, "admin": { + "api-keys": { + "detail": { + "active": "Actieve keys ({count})", + "revoked": "Ingetrokken keys ({count})" + }, + "empty": "Geen gebruikers met API Keys gevonden.", + "name": "API Keys", + "never": "nooit", + "order": { + "active-keys": "Actieve keys", + "comments": "Aantal reacties", + "last-used": "Laatste activiteit", + "posts": "Aantal berichten" + }, + "revoke": { + "confirm": "Intrekken", + "message": "Weet je zeker dat je de API Key \"{name}\" van {user} wilt intrekken?", + "success": "API Key is ingetrokken", + "title": "API Key intrekken" + }, + "revoke-all": "Alle keys van deze gebruiker intrekken", + "revoke-all-confirm": "Alles intrekken", + "revoke-all-message": "Weet je zeker dat je ALLE API Keys van {user} wilt intrekken? Deze actie kan niet ongedaan worden gemaakt.", + "revoke-all-short": "Alles intrekken", + "revoke-all-success": "{count} API Keys van {user} zijn ingetrokken", + "revoke-all-title": "Alle API Keys intrekken", + "revoke-key": "Key intrekken", + "revoked-at": "Ingetrokken op", + "show-keys": "Keys tonen", + "sort-by": "Sorteren op", + "table": { + "actions": "Acties", + "active": "Actief", + "comments": "Reacties", + "last-activity": "Laatste activiteit", + "name": "Naam", + "posts": "Berichten", + "prefix": "Key", + "revoked-count": "Ingetrokken", + "user": "Gebruiker" + } + }, "badges": { "description": "Configureer de beschikbare badges voor deze gebruiker", "noBadges": "Er zijn geen badges beschikbaar", @@ -1064,6 +1106,51 @@ "title": "Zoekresultaten" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} dagen", + "expiry-label": "Geldigheid", + "expiry-never": "Geen vervaldatum", + "limit-reached": "Je hebt het maximum van {max} actieve API Keys bereikt. Trek een bestaande key in om een nieuwe aan te maken.", + "name-label": "Naam", + "submit": "Key aanmaken", + "success": "API Key is aangemaakt", + "title": "Nieuwe API Key aanmaken" + }, + "list": { + "actions": "Acties", + "created-at": "Aangemaakt op", + "empty": "Je hebt nog geen API Keys.", + "expired": "verlopen", + "expires": "Verloopt", + "last-used": "Laatst gebruikt", + "name": "Naam", + "never": "nooit", + "never-expires": "nooit", + "prefix": "Key", + "revoke": "Key intrekken", + "revoked": "ingetrokken", + "title": "Mijn API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Intrekken", + "message": "Weet je zeker dat je de API Key \"{name}\" wilt intrekken? Deze actie kan niet ongedaan worden gemaakt.", + "success": "API Key \"{name}\" is ingetrokken", + "title": "API Key intrekken" + }, + "revoked-list": { + "revoked-at": "Ingetrokken op", + "title": "Ingetrokken keys ({count})" + }, + "secret": { + "copied": "API Key naar klembord gekopieerd", + "copy": "Kopiëren", + "copy-failed": "Kopiëren naar klembord mislukt", + "title": "Je nieuwe API Key (slechts één keer zichtbaar!)", + "warning": "Sla deze key nu op. Je kunt hem na het sluiten van deze pagina niet meer zien." + } + }, "badges": { "click-to-select": "Klik op een lege plek om een badge toe te voegen.", "click-to-use": "Klik op een badge om deze in de geselecteerde plek te plaatsen.", diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 5689f14d2..c04f99eac 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -14,6 +14,48 @@ "search": "Szukaj" }, "admin": { + "api-keys": { + "detail": { + "active": "Aktywne keys ({count})", + "revoked": "Unieważnione keys ({count})" + }, + "empty": "Nie znaleziono użytkowników z API Keys.", + "name": "API Keys", + "never": "nigdy", + "order": { + "active-keys": "Aktywne keys", + "comments": "Liczba komentarzy", + "last-used": "Ostatnia aktywność", + "posts": "Liczba postów" + }, + "revoke": { + "confirm": "Unieważnij", + "message": "Czy na pewno chcesz unieważnić API Key \"{name}\" użytkownika {user}?", + "success": "API Key został unieważniony", + "title": "Unieważnij API Key" + }, + "revoke-all": "Unieważnij wszystkie keys tego użytkownika", + "revoke-all-confirm": "Unieważnij wszystkie", + "revoke-all-message": "Czy na pewno chcesz unieważnić WSZYSTKIE API Keys użytkownika {user}? Tej akcji nie można cofnąć.", + "revoke-all-short": "Unieważnij wszystkie", + "revoke-all-success": "{count} API Keys użytkownika {user} zostało unieważnionych", + "revoke-all-title": "Unieważnij wszystkie API Keys", + "revoke-key": "Unieważnij key", + "revoked-at": "Unieważniony", + "show-keys": "Pokaż keys", + "sort-by": "Sortuj według", + "table": { + "actions": "Akcje", + "active": "Aktywne", + "comments": "Komentarze", + "last-activity": "Ostatnia aktywność", + "name": "Nazwa", + "posts": "Posty", + "prefix": "Key", + "revoked-count": "Unieważnione", + "user": "Użytkownik" + } + }, "badges": { "description": "Skonfiguruj dostępne odznaki dla tego użytkownika", "noBadges": "Brak dostępnych odznak", @@ -1064,6 +1106,51 @@ "title": "Wyniki wyszukiwania" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} dni", + "expiry-label": "Ważność", + "expiry-never": "Bez daty wygaśnięcia", + "limit-reached": "Osiągnąłeś maksymalną liczbę {max} aktywnych API Keys. Unieważnij istniejący key, aby utworzyć nowy.", + "name-label": "Nazwa", + "submit": "Utwórz key", + "success": "API Key został utworzony", + "title": "Utwórz nowy API Key" + }, + "list": { + "actions": "Akcje", + "created-at": "Utworzony", + "empty": "Nie masz jeszcze żadnych API Keys.", + "expired": "wygasły", + "expires": "Wygasa", + "last-used": "Ostatnie użycie", + "name": "Nazwa", + "never": "nigdy", + "never-expires": "nigdy", + "prefix": "Key", + "revoke": "Unieważnij key", + "revoked": "unieważniony", + "title": "Moje API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Unieważnij", + "message": "Czy na pewno chcesz unieważnić API Key \"{name}\"? Tej akcji nie można cofnąć.", + "success": "API Key \"{name}\" został unieważniony", + "title": "Unieważnij API Key" + }, + "revoked-list": { + "revoked-at": "Unieważniony", + "title": "Unieważnione keys ({count})" + }, + "secret": { + "copied": "API Key skopiowany do schowka", + "copy": "Kopiuj", + "copy-failed": "Nie udało się skopiować do schowka", + "title": "Twój nowy API Key (widoczny tylko raz!)", + "warning": "Zapisz ten key teraz. Nie będziesz mógł go zobaczyć ponownie po zamknięciu tej strony." + } + }, "badges": { "click-to-select": "Kliknij na puste miejsce, aby dodać odznakę.", "click-to-use": "Kliknij na odznakę, aby umieścić ją w wybranym miejscu.", diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index a6c4f55f7..55987f512 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -14,6 +14,48 @@ "search": "Pesquisar" }, "admin": { + "api-keys": { + "detail": { + "active": "Keys ativas ({count})", + "revoked": "Keys revogadas ({count})" + }, + "empty": "Nenhum utilizador com API Keys encontrado.", + "name": "API Keys", + "never": "nunca", + "order": { + "active-keys": "Keys ativas", + "comments": "Número de comentários", + "last-used": "Última atividade", + "posts": "Número de publicações" + }, + "revoke": { + "confirm": "Revogar", + "message": "Tens a certeza de que queres revogar o API Key \"{name}\" de {user}?", + "success": "API Key revogada com sucesso", + "title": "Revogar API Key" + }, + "revoke-all": "Revogar todas as keys deste utilizador", + "revoke-all-confirm": "Revogar todas", + "revoke-all-message": "Tens a certeza de que queres revogar TODAS as API Keys de {user}? Esta ação não pode ser desfeita.", + "revoke-all-short": "Revogar todas", + "revoke-all-success": "{count} API Keys de {user} foram revogadas", + "revoke-all-title": "Revogar todas as API Keys", + "revoke-key": "Revogar key", + "revoked-at": "Revogada em", + "show-keys": "Mostrar keys", + "sort-by": "Ordenar por", + "table": { + "actions": "Ações", + "active": "Ativas", + "comments": "Comentários", + "last-activity": "Última atividade", + "name": "Nome", + "posts": "Publicações", + "prefix": "Key", + "revoked-count": "Revogadas", + "user": "Utilizador" + } + }, "badges": { "description": "Configurar os emblemas disponíveis para este usuário", "noBadges": "Não há emblemas disponíveis", @@ -1064,6 +1106,51 @@ "title": "Resultados da busca" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} dias", + "expiry-label": "Validade", + "expiry-never": "Sem data de expiração", + "limit-reached": "Atingiste o máximo de {max} API Keys ativas. Revoga uma key existente para criar uma nova.", + "name-label": "Nome", + "submit": "Criar key", + "success": "API Key criada com sucesso", + "title": "Criar novo API Key" + }, + "list": { + "actions": "Ações", + "created-at": "Criada em", + "empty": "Ainda não tens API Keys.", + "expired": "expirada", + "expires": "Expira", + "last-used": "Última utilização", + "name": "Nome", + "never": "nunca", + "never-expires": "nunca", + "prefix": "Key", + "revoke": "Revogar key", + "revoked": "revogada", + "title": "As minhas API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Revogar", + "message": "Tens a certeza de que queres revogar o API Key \"{name}\"? Esta ação não pode ser desfeita.", + "success": "API Key \"{name}\" foi revogada", + "title": "Revogar API Key" + }, + "revoked-list": { + "revoked-at": "Revogada em", + "title": "Keys revogadas ({count})" + }, + "secret": { + "copied": "API Key copiada para a área de transferência", + "copy": "Copiar", + "copy-failed": "Falha ao copiar para a área de transferência", + "title": "O teu novo API Key (visível apenas uma vez!)", + "warning": "Guarda esta key agora. Não a poderás ver novamente depois de fechar esta página." + } + }, "badges": { "click-to-select": "Clique num espaço vazio para adicionar uma medalha.", "click-to-use": "Clique numa medalha para a colocar no espaço selecionado.", diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index 29a3cd7b9..7ea63dd07 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -14,6 +14,48 @@ "search": "Поиск" }, "admin": { + "api-keys": { + "detail": { + "active": "Активные keys ({count})", + "revoked": "Отозванные keys ({count})" + }, + "empty": "Пользователи с API Keys не найдены.", + "name": "API Keys", + "never": "никогда", + "order": { + "active-keys": "Активные keys", + "comments": "Количество комментариев", + "last-used": "Последняя активность", + "posts": "Количество постов" + }, + "revoke": { + "confirm": "Отозвать", + "message": "Ты уверен, что хочешь отозвать API Key \"{name}\" пользователя {user}?", + "success": "API Key успешно отозван", + "title": "Отозвать API Key" + }, + "revoke-all": "Отозвать все keys этого пользователя", + "revoke-all-confirm": "Отозвать все", + "revoke-all-message": "Ты уверен, что хочешь отозвать ВСЕ API Keys пользователя {user}? Это действие нельзя отменить.", + "revoke-all-short": "Отозвать все", + "revoke-all-success": "{count} API Keys пользователя {user} были отозваны", + "revoke-all-title": "Отозвать все API Keys", + "revoke-key": "Отозвать key", + "revoked-at": "Отозван", + "show-keys": "Показать keys", + "sort-by": "Сортировать по", + "table": { + "actions": "Действия", + "active": "Активные", + "comments": "Комментарии", + "last-activity": "Последняя активность", + "name": "Имя", + "posts": "Посты", + "prefix": "Key", + "revoked-count": "Отозванные", + "user": "Пользователь" + } + }, "badges": { "description": "Настроить доступные значки для этого пользователя", "noBadges": "Нет доступных значков", @@ -1064,6 +1106,51 @@ "title": "Результаты поиска" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} дней", + "expiry-label": "Срок действия", + "expiry-never": "Без срока действия", + "limit-reached": "Ты достиг максимума в {max} активных API Keys. Отзови существующий key, чтобы создать новый.", + "name-label": "Имя", + "submit": "Создать key", + "success": "API Key успешно создан", + "title": "Создать новый API Key" + }, + "list": { + "actions": "Действия", + "created-at": "Создан", + "empty": "У тебя ещё нет API Keys.", + "expired": "истёк", + "expires": "Истекает", + "last-used": "Последнее использование", + "name": "Имя", + "never": "никогда", + "never-expires": "никогда", + "prefix": "Key", + "revoke": "Отозвать key", + "revoked": "отозван", + "title": "Мои API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Отозвать", + "message": "Ты уверен, что хочешь отозвать API Key \"{name}\"? Это действие нельзя отменить.", + "success": "API Key \"{name}\" был отозван", + "title": "Отозвать API Key" + }, + "revoked-list": { + "revoked-at": "Отозван", + "title": "Отозванные keys ({count})" + }, + "secret": { + "copied": "API Key скопирован в буфер обмена", + "copy": "Копировать", + "copy-failed": "Не удалось скопировать в буфер обмена", + "title": "Твой новый API Key (виден только один раз!)", + "warning": "Сохрани этот key сейчас. Ты не сможешь увидеть его снова после закрытия этой страницы." + } + }, "badges": { "click-to-select": "Нажмите на пустое место, чтобы добавить награду.", "click-to-use": "Нажмите на награду, чтобы поместить её в выбранное место.", diff --git a/webapp/locales/sq.json b/webapp/locales/sq.json index 446d32091..c8a1ae3e6 100644 --- a/webapp/locales/sq.json +++ b/webapp/locales/sq.json @@ -14,6 +14,48 @@ "search": "Kërko" }, "admin": { + "api-keys": { + "detail": { + "active": "Keys aktive ({count})", + "revoked": "Keys të revokuara ({count})" + }, + "empty": "Nuk u gjetën përdorues me API Keys.", + "name": "API Keys", + "never": "kurrë", + "order": { + "active-keys": "Keys aktive", + "comments": "Numri i komenteve", + "last-used": "Aktiviteti i fundit", + "posts": "Numri i postimeve" + }, + "revoke": { + "confirm": "Revoko", + "message": "A je i sigurt që dëshiron të revokosh API Key \"{name}\" të {user}?", + "success": "API Key u revokua me sukses", + "title": "Revoko API Key" + }, + "revoke-all": "Revoko të gjitha keys e këtij përdoruesi", + "revoke-all-confirm": "Revoko të gjitha", + "revoke-all-message": "A je i sigurt që dëshiron të revokosh TË GJITHA API Keys e {user}? Ky veprim nuk mund të zhbëhet.", + "revoke-all-short": "Revoko të gjitha", + "revoke-all-success": "{count} API Keys të {user} u revokuan", + "revoke-all-title": "Revoko të gjitha API Keys", + "revoke-key": "Revoko key", + "revoked-at": "Revokuar më", + "show-keys": "Shfaq keys", + "sort-by": "Rendit sipas", + "table": { + "actions": "Veprime", + "active": "Aktive", + "comments": "Komente", + "last-activity": "Aktiviteti i fundit", + "name": "Emri", + "posts": "Postime", + "prefix": "Key", + "revoked-count": "Revokuara", + "user": "Përdoruesi" + } + }, "badges": { "description": "Konfiguro embelemat e disponueshme për këtë përdorues", "noBadges": "Nuk ka emblema të disponueshme", @@ -1064,6 +1106,51 @@ "title": "Rezultatet e kërkimit" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} ditë", + "expiry-label": "Vlefshmëria", + "expiry-never": "Pa datë skadimi", + "limit-reached": "Ke arritur maksimumin e {max} API Keys aktive. Revoko një key ekzistues për të krijuar një të ri.", + "name-label": "Emri", + "submit": "Krijo key", + "success": "API Key u krijua me sukses", + "title": "Krijo API Key të ri" + }, + "list": { + "actions": "Veprime", + "created-at": "Krijuar më", + "empty": "Nuk ke ende API Keys.", + "expired": "skaduar", + "expires": "Skadon", + "last-used": "Përdorimi i fundit", + "name": "Emri", + "never": "kurrë", + "never-expires": "kurrë", + "prefix": "Key", + "revoke": "Revoko key", + "revoked": "revokuar", + "title": "API Keys e mia" + }, + "name": "API Keys", + "revoke": { + "confirm": "Revoko", + "message": "A je i sigurt që dëshiron të revokosh API Key \"{name}\"? Ky veprim nuk mund të zhbëhet.", + "success": "API Key \"{name}\" u revokua", + "title": "Revoko API Key" + }, + "revoked-list": { + "revoked-at": "Revokuar më", + "title": "Keys të revokuara ({count})" + }, + "secret": { + "copied": "API Key u kopjua në clipboard", + "copy": "Kopjo", + "copy-failed": "Kopjimi në clipboard dështoi", + "title": "API Key yt i ri (i dukshëm vetëm një herë!)", + "warning": "Ruaje këtë key tani. Nuk do të mundesh ta shohësh përsëri pasi të mbyllësh këtë faqe." + } + }, "badges": { "click-to-select": "Kliko në një hapësirë boshe për të shtuar një emblemë.", "click-to-use": "Kliko mbi një emblemë për ta përdorur në hapësirën e zgjedhur.", diff --git a/webapp/locales/uk.json b/webapp/locales/uk.json index fcb900940..e79db8f59 100644 --- a/webapp/locales/uk.json +++ b/webapp/locales/uk.json @@ -14,6 +14,48 @@ "search": "Пошук" }, "admin": { + "api-keys": { + "detail": { + "active": "Активні keys ({count})", + "revoked": "Відкликані keys ({count})" + }, + "empty": "Користувачів з API Keys не знайдено.", + "name": "API Keys", + "never": "ніколи", + "order": { + "active-keys": "Активні keys", + "comments": "Кількість коментарів", + "last-used": "Остання активність", + "posts": "Кількість постів" + }, + "revoke": { + "confirm": "Відкликати", + "message": "Ти впевнений, що хочеш відкликати API Key \"{name}\" користувача {user}?", + "success": "API Key успішно відкликано", + "title": "Відкликати API Key" + }, + "revoke-all": "Відкликати всі keys цього користувача", + "revoke-all-confirm": "Відкликати всі", + "revoke-all-message": "Ти впевнений, що хочеш відкликати ВСІ API Keys користувача {user}? Цю дію не можна скасувати.", + "revoke-all-short": "Відкликати всі", + "revoke-all-success": "{count} API Keys користувача {user} було відкликано", + "revoke-all-title": "Відкликати всі API Keys", + "revoke-key": "Відкликати key", + "revoked-at": "Відкликано", + "show-keys": "Показати keys", + "sort-by": "Сортувати за", + "table": { + "actions": "Дії", + "active": "Активні", + "comments": "Коментарі", + "last-activity": "Остання активність", + "name": "Ім'я", + "posts": "Пости", + "prefix": "Key", + "revoked-count": "Відкликані", + "user": "Користувач" + } + }, "badges": { "description": "Налаштувати доступні значки для цього користувача", "noBadges": "Немає доступних значків", @@ -1064,6 +1106,51 @@ "title": "Результати пошуку" }, "settings": { + "api-keys": { + "create": { + "expiry-days": "{days} днів", + "expiry-label": "Термін дії", + "expiry-never": "Без терміну дії", + "limit-reached": "Ти досяг максимуму в {max} активних API Keys. Відклич існуючий key, щоб створити новий.", + "name-label": "Ім'я", + "submit": "Створити key", + "success": "API Key успішно створено", + "title": "Створити новий API Key" + }, + "list": { + "actions": "Дії", + "created-at": "Створено", + "empty": "У тебе ще немає API Keys.", + "expired": "закінчився", + "expires": "Закінчується", + "last-used": "Останнє використання", + "name": "Ім'я", + "never": "ніколи", + "never-expires": "ніколи", + "prefix": "Key", + "revoke": "Відкликати key", + "revoked": "відкликано", + "title": "Мої API Keys" + }, + "name": "API Keys", + "revoke": { + "confirm": "Відкликати", + "message": "Ти впевнений, що хочеш відкликати API Key \"{name}\"? Цю дію не можна скасувати.", + "success": "API Key \"{name}\" було відкликано", + "title": "Відкликати API Key" + }, + "revoked-list": { + "revoked-at": "Відкликано", + "title": "Відкликані keys ({count})" + }, + "secret": { + "copied": "API Key скопійовано до буфера обміну", + "copy": "Копіювати", + "copy-failed": "Не вдалося скопіювати до буфера обміну", + "title": "Твій новий API Key (видимий лише один раз!)", + "warning": "Збережи цей key зараз. Ти не зможеш побачити його знову після закриття цієї сторінки." + } + }, "badges": { "click-to-select": "Натисніть на порожнє місце, щоб додати значок.", "click-to-use": "Натисніть на значок, щоб використати його у вибраному місці.", diff --git a/webapp/pages/admin.spec.js b/webapp/pages/admin.spec.js index aa6eceab1..e16972064 100644 --- a/webapp/pages/admin.spec.js +++ b/webapp/pages/admin.spec.js @@ -14,6 +14,7 @@ describe('admin.vue', () => { beforeEach(() => { mocks = { $t: jest.fn(), + $env: { API_KEYS_ENABLED: false }, } }) diff --git a/webapp/pages/admin.vue b/webapp/pages/admin.vue index ff9c1eae6..5c1243a30 100644 --- a/webapp/pages/admin.vue +++ b/webapp/pages/admin.vue @@ -64,6 +64,9 @@ export default { name: this.$t('admin.donations.name'), path: '/admin/donations', }, + ...(this.$env.API_KEYS_ENABLED + ? [{ name: this.$t('admin.api-keys.name'), path: `/admin/api-keys` }] + : []), // TODO implement /* { name: this.$t('admin.settings.name'), diff --git a/webapp/pages/admin/api-keys.spec.js b/webapp/pages/admin/api-keys.spec.js new file mode 100644 index 000000000..38b02c0b6 --- /dev/null +++ b/webapp/pages/admin/api-keys.spec.js @@ -0,0 +1,393 @@ +import Vue from 'vue' +import { mount } from '@vue/test-utils' +import flushPromises from 'flush-promises' +import ApiKeys from './api-keys.vue' + +const localVue = global.localVue + +// Override dateTime filter to avoid locale dependency +Vue.filter('dateTime', (value) => value || '') + +describe('admin/api-keys.vue', () => { + let wrapper, mocks + + const mutateMock = jest.fn() + const refetchMock = jest.fn() + const queryMock = jest.fn() + + const userEntry = (overrides = {}) => ({ + user: { id: 'u1', name: 'Peter', slug: 'peter' }, + activeCount: 2, + revokedCount: 1, + postsCount: 42, + commentsCount: 15, + lastActivity: '2026-04-02T12:00:00Z', + ...overrides, + }) + + const userEntryNeverUsed = () => + userEntry({ + user: { id: 'u2', name: 'Bob', slug: 'bob' }, + activeCount: 1, + revokedCount: 0, + postsCount: 0, + commentsCount: 0, + lastActivity: null, + }) + + const activeKeyDetail = (overrides = {}) => ({ + id: 'ak1', + name: 'CI Bot', + keyPrefix: 'oak_cibot123', + createdAt: '2026-03-01T00:00:00Z', + lastUsedAt: '2026-04-02T12:00:00Z', + expiresAt: null, + disabled: false, + disabledAt: null, + ...overrides, + }) + + const revokedKeyDetail = (overrides = {}) => ({ + id: 'ak-old', + name: 'Old Script', + keyPrefix: 'oak_old12345', + createdAt: '2025-01-01T00:00:00Z', + lastUsedAt: '2025-05-01T00:00:00Z', + expiresAt: null, + disabled: true, + disabledAt: '2025-06-01T00:00:00Z', + ...overrides, + }) + + beforeEach(() => { + mutateMock.mockReset() + refetchMock.mockReset() + queryMock.mockReset() + + mocks = { + $t: jest.fn((key) => key), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $apollo: { + mutate: mutateMock, + query: queryMock, + queries: { + apiKeyUsers: { + refetch: refetchMock, + }, + }, + }, + } + }) + + const Wrapper = (data = {}) => { + return mount(ApiKeys, { + mocks, + localVue, + stubs: { + 'nuxt-link': true, + 'confirm-modal': true, + 'pagination-buttons': true, + 'user-teaser': { template: '@{{ user.slug }}', props: ['user'] }, + 'date-time': { template: '{{ dateTime }}', props: ['dateTime'] }, + 'os-spinner': true, + }, + data: () => ({ + apiKeyUsers: [], + ...data, + }), + }) + } + + describe('renders', () => { + it('shows title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('admin.api-keys.name') + }) + + it('shows empty state when no users', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('admin.api-keys.empty') + }) + + it('shows user table', () => { + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + expect(wrapper.text()).toContain('@peter') + expect(wrapper.text()).toContain('42') + expect(wrapper.text()).toContain('15') + }) + + it('shows "never" for null lastActivity', () => { + wrapper = Wrapper({ apiKeyUsers: [userEntryNeverUsed()] }) + expect(wrapper.text()).toContain('admin.api-keys.never') + }) + + it('shows revoke-all button only when active keys exist', () => { + wrapper = Wrapper({ + apiKeyUsers: [userEntry({ activeCount: 0, revokedCount: 3 })], + }) + expect(wrapper.find('button[aria-label="admin.api-keys.revoke-all"]').exists()).toBe(false) + }) + + it('shows revoke-all button when active keys exist', () => { + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + expect(wrapper.find('button[aria-label="admin.api-keys.revoke-all"]').exists()).toBe(true) + }) + }) + + describe('expand user detail', () => { + it('loads keys on expand click', async () => { + queryMock.mockResolvedValue({ + data: { + apiKeysForUser: [activeKeyDetail(), revokedKeyDetail()], + }, + }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.find('button[aria-label="admin.api-keys.show-keys"]').trigger('click') + await flushPromises() + expect(wrapper.vm.expandedUserId).toBe('u1') + expect(wrapper.vm.userKeys).toHaveLength(2) + }) + + it('separates active and revoked keys', async () => { + queryMock.mockResolvedValue({ + data: { + apiKeysForUser: [activeKeyDetail(), revokedKeyDetail()], + }, + }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.find('button[aria-label="admin.api-keys.show-keys"]').trigger('click') + await flushPromises() + expect(wrapper.vm.activeUserKeys).toHaveLength(1) + expect(wrapper.vm.activeUserKeys[0].name).toBe('CI Bot') + expect(wrapper.vm.revokedUserKeys).toHaveLength(1) + expect(wrapper.vm.revokedUserKeys[0].name).toBe('Old Script') + }) + + it('collapses on second click', async () => { + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.setData({ + expandedUserId: 'u1', + userKeys: [activeKeyDetail()], + }) + await wrapper.vm.toggleUser('u1') + expect(wrapper.vm.expandedUserId).toBeNull() + expect(wrapper.vm.userKeys).toBeNull() + }) + + it('shows detail section with active keys heading', async () => { + queryMock.mockResolvedValue({ + data: { apiKeysForUser: [activeKeyDetail()] }, + }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.vm.toggleUser('u1') + await flushPromises() + await wrapper.vm.$nextTick() + await flushPromises() + expect(wrapper.text()).toContain('admin.api-keys.detail.active') + expect(wrapper.text()).toContain('CI Bot') + }) + + it('shows revoked keys heading in detail', async () => { + queryMock.mockResolvedValue({ + data: { apiKeysForUser: [revokedKeyDetail()] }, + }) + wrapper = Wrapper({ apiKeyUsers: [userEntry({ activeCount: 0, revokedCount: 1 })] }) + await wrapper.vm.toggleUser('u1') + await flushPromises() + await wrapper.vm.$nextTick() + await flushPromises() + expect(wrapper.text()).toContain('admin.api-keys.detail.revoked') + }) + + it('shows error toast on query failure', async () => { + queryMock.mockRejectedValue(new Error('Query failed')) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.vm.toggleUser('u1') + await flushPromises() + expect(mocks.$toast.error).toHaveBeenCalledWith('Query failed') + }) + + it('discards stale response when user switches during load', async () => { + let resolveFirst + queryMock + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockResolvedValueOnce({ + data: { apiKeysForUser: [activeKeyDetail({ id: 'ak-second', name: 'Second' })] }, + }) + wrapper = Wrapper({ + apiKeyUsers: [userEntry(), userEntry({ user: { id: 'u2', name: 'Bob', slug: 'bob' } })], + }) + // Start loading user u1 + const firstToggle = wrapper.vm.toggleUser('u1') + await wrapper.vm.$nextTick() + // Switch to u2 before u1 finishes + await wrapper.vm.toggleUser('u2') + await flushPromises() + // Now resolve u1's stale response + resolveFirst({ + data: { apiKeysForUser: [activeKeyDetail({ id: 'ak-first', name: 'Stale' })] }, + }) + await firstToggle + await flushPromises() + // Should show u2's keys, not u1's stale response + expect(wrapper.vm.expandedUserId).toBe('u2') + expect(wrapper.vm.userKeys[0].name).toBe('Second') + }) + + it('sets detailLoading on the expand button', async () => { + let resolveQuery + queryMock.mockReturnValue( + new Promise((resolve) => { + resolveQuery = resolve + }), + ) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + const togglePromise = wrapper.vm.toggleUser('u1') + await wrapper.vm.$nextTick() + expect(wrapper.vm.detailLoading).toBe(true) + resolveQuery({ data: { apiKeysForUser: [activeKeyDetail()] } }) + await togglePromise + await flushPromises() + expect(wrapper.vm.detailLoading).toBe(false) + }) + }) + + describe('revoke single key', () => { + it('opens confirm modal', () => { + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + const key = activeKeyDetail() + const entry = userEntry() + wrapper.vm.confirmRevokeKey(key, entry) + expect(wrapper.vm.showModal).toBe(true) + expect(wrapper.vm.modalData.messageParams).toEqual({ + name: 'CI Bot', + user: 'Peter', + }) + }) + + it('calls adminRevokeApiKey and refetches', async () => { + mutateMock.mockResolvedValue({ data: { adminRevokeApiKey: true } }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.vm.revokeKey('ak1', 'u1') + await flushPromises() + expect(mutateMock).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'ak1' } })) + expect(refetchMock).toHaveBeenCalled() + expect(mocks.$toast.success).toHaveBeenCalled() + }) + + it('collapses detail after revoke', async () => { + mutateMock.mockResolvedValue({ data: { adminRevokeApiKey: true } }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.setData({ expandedUserId: 'u1' }) + await wrapper.vm.revokeKey('ak1', 'u1') + await flushPromises() + expect(wrapper.vm.expandedUserId).toBeNull() + }) + + it('shows error toast on failure', async () => { + mutateMock.mockRejectedValue(new Error('Revoke failed')) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await expect(wrapper.vm.revokeKey('ak1', 'u1')).rejects.toThrow('Revoke failed') + await flushPromises() + expect(mocks.$toast.error).toHaveBeenCalledWith('Revoke failed') + }) + }) + + describe('revoke all keys', () => { + it('opens confirm modal', () => { + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + wrapper.vm.confirmRevokeAll(userEntry()) + expect(wrapper.vm.showModal).toBe(true) + expect(wrapper.vm.modalData.messageParams).toEqual({ user: 'Peter' }) + }) + + it('calls adminRevokeUserApiKeys and refetches', async () => { + mutateMock.mockResolvedValue({ data: { adminRevokeUserApiKeys: 2 } }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.vm.revokeAllKeys('u1', 'Peter') + await flushPromises() + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ variables: { userId: 'u1' } }), + ) + expect(refetchMock).toHaveBeenCalled() + expect(mocks.$toast.success).toHaveBeenCalledWith('admin.api-keys.revoke-all-success') + }) + + it('collapses detail and clears keys after bulk revoke', async () => { + mutateMock.mockResolvedValue({ data: { adminRevokeUserApiKeys: 2 } }) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await wrapper.setData({ expandedUserId: 'u1', userKeys: [activeKeyDetail()] }) + await wrapper.vm.revokeAllKeys('u1', 'Peter') + await flushPromises() + expect(wrapper.vm.expandedUserId).toBeNull() + expect(wrapper.vm.userKeys).toBeNull() + }) + + it('shows error toast on failure', async () => { + mutateMock.mockRejectedValue(new Error('Bulk revoke failed')) + wrapper = Wrapper({ apiKeyUsers: [userEntry()] }) + await expect(wrapper.vm.revokeAllKeys('u1', 'Peter')).rejects.toThrow('Bulk revoke failed') + await flushPromises() + expect(mocks.$toast.error).toHaveBeenCalledWith('Bulk revoke failed') + }) + }) + + describe('pagination', () => { + it('next increments offset', () => { + wrapper = Wrapper() + wrapper.vm.next() + expect(wrapper.vm.offset).toBe(20) + }) + + it('back decrements offset', async () => { + wrapper = Wrapper() + await wrapper.setData({ offset: 20 }) + wrapper.vm.back() + expect(wrapper.vm.offset).toBe(0) + }) + + it('back does not go below 0', () => { + wrapper = Wrapper() + wrapper.vm.back() + expect(wrapper.vm.offset).toBe(0) + }) + + it('hasPrevious is false at offset 0', () => { + wrapper = Wrapper() + expect(wrapper.vm.hasPrevious).toBe(false) + }) + + it('hasPrevious is true at offset > 0', async () => { + wrapper = Wrapper() + await wrapper.setData({ offset: 20 }) + expect(wrapper.vm.hasPrevious).toBe(true) + }) + + it('sets hasNext to true when more results than pageSize', () => { + wrapper = Wrapper() + const entries = Array.from({ length: 21 }, (_, i) => + userEntry({ user: { id: `u${i}`, name: `User ${i}`, slug: `user-${i}` } }), + ) + const updateFn = wrapper.vm.$options.apollo.apiKeyUsers.update.bind(wrapper.vm) + const result = updateFn({ apiKeyUsers: entries }) + expect(wrapper.vm.hasNext).toBe(true) + expect(result).toHaveLength(20) + }) + + it('sets hasNext to false when results fit in page', () => { + wrapper = Wrapper() + const entries = [userEntry()] + const updateFn = wrapper.vm.$options.apollo.apiKeyUsers.update.bind(wrapper.vm) + const result = updateFn({ apiKeyUsers: entries }) + expect(wrapper.vm.hasNext).toBe(false) + expect(result).toHaveLength(1) + }) + }) +}) diff --git a/webapp/pages/admin/api-keys.vue b/webapp/pages/admin/api-keys.vue new file mode 100644 index 000000000..f4a8b6230 --- /dev/null +++ b/webapp/pages/admin/api-keys.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue index a7881b56c..4f259e09e 100644 --- a/webapp/pages/groups/_id/_slug.vue +++ b/webapp/pages/groups/_id/_slug.vue @@ -665,8 +665,8 @@ export default { } -