From fa5a46f379f00d8cef9b2f8741adbb2807a49f28 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 00:43:17 -0400 Subject: [PATCH 01/61] basic support for Shared Access Accounts --- locales/index.d.ts | 48 ++++++ ...750478202328-create-shared-access-token.js | 22 +++ packages/backend/src/di-symbols.ts | 1 + .../backend/src/models/RepositoryModule.ts | 9 ++ .../backend/src/models/SkSharedAccessToken.ts | 51 +++++++ packages/backend/src/models/_.ts | 5 +- packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/endpoint-list.ts | 2 + .../api/endpoints/i/shared-access/list.ts | 90 +++++++++++ .../api/endpoints/i/shared-access/login.ts | 80 ++++++++++ .../server/api/endpoints/miauth/gen-token.ts | 67 +++++++-- packages/frontend/src/accounts.ts | 28 ++++ .../src/components/MkTokenGenerateWindow.vue | 75 ++++++++-- .../components/SkSigninSharedAccessDialog.vue | 126 ++++++++++++++++ .../frontend/src/pages/settings/accounts.vue | 13 +- .../frontend/src/pages/settings/connect.vue | 3 +- packages/misskey-js/etc/misskey-js.api.md | 12 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 24 +++ packages/misskey-js/src/autogen/endpoint.ts | 5 + packages/misskey-js/src/autogen/entities.ts | 3 + packages/misskey-js/src/autogen/types.ts | 141 ++++++++++++++++++ sharkey-locales/en-US.yml | 14 ++ 22 files changed, 789 insertions(+), 32 deletions(-) create mode 100644 packages/backend/migration/1750478202328-create-shared-access-token.js create mode 100644 packages/backend/src/models/SkSharedAccessToken.ts create mode 100644 packages/backend/src/server/api/endpoints/i/shared-access/list.ts create mode 100644 packages/backend/src/server/api/endpoints/i/shared-access/login.ts create mode 100644 packages/frontend/src/components/SkSigninSharedAccessDialog.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index 880ee4bf56..e80b71075a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13459,6 +13459,54 @@ export interface Locale extends ILocale { * Hide ads */ "hideAds": string; + /** + * Apps using this token will only have access to the functions listed below. + */ + "permissionsDescription": string; + /** + * Shared account + */ + "sharedAccount": string; + /** + * Shared access + */ + "sharedAccess": string; + /** + * Any accounts listed here will be granted access to the token and may use it to access this account. + */ + "sharedAccessDescription": string; + /** + * Share access + */ + "addGrantee": string; + /** + * Remove access + */ + "removeGrantee": string; + /** + * Login with shared access + */ + "loginWithSharedAccess": string; + /** + * Login with granted access to a shared account + */ + "loginWithSharedAccessDescription": string; + /** + * You have not been granted shared access to any accounts + */ + "noSharedAccess": string; + /** + * Expand + */ + "expand": string; + /** + * Collapse + */ + "collapse": string; + /** + * Permissions + */ + "permissions": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1750478202328-create-shared-access-token.js b/packages/backend/migration/1750478202328-create-shared-access-token.js new file mode 100644 index 0000000000..1f7146e4fe --- /dev/null +++ b/packages/backend/migration/1750478202328-create-shared-access-token.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateSharedAccessToken1750478202328 { + name = 'CreateSharedAccessToken1750478202328' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "shared_access_token" ("accessTokenId" character varying(32) NOT NULL, "granteeId" character varying(32) NOT NULL, CONSTRAINT "PK_b741ebcd3988295f4140a9f31b4" PRIMARY KEY ("accessTokenId")); COMMENT ON COLUMN "shared_access_token"."accessTokenId" IS 'ID of the access token that is shared'; COMMENT ON COLUMN "shared_access_token"."granteeId" IS 'ID of the user who is allowed to use this access token'`); + await queryRunner.query(`CREATE INDEX "IDX_shared_access_token_granteeId" ON "shared_access_token" ("granteeId") `); + await queryRunner.query(`ALTER TABLE "shared_access_token" ADD CONSTRAINT "FK_shared_access_token_accessTokenId" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "shared_access_token" ADD CONSTRAINT "FK_shared_access_token_granteeId" FOREIGN KEY ("granteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "shared_access_token" DROP CONSTRAINT "FK_shared_access_token_granteeId"`); + await queryRunner.query(`ALTER TABLE "shared_access_token" DROP CONSTRAINT "FK_shared_access_token_accessTokenId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_shared_access_token_granteeId"`); + await queryRunner.query(`DROP TABLE "shared_access_token"`); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 099d48c81a..56e7abfd08 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -96,5 +96,6 @@ export const DI = { bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), noteScheduleRepository: Symbol('noteScheduleRepository'), + sharedAccessToken: Symbol('sharedAccessToken'), //#endregion }; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 5e0154fe50..25f48759e6 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -88,6 +88,7 @@ import { SkApContext, SkApFetchLog, SkApInboxLog, + SkSharedAccessToken, } from './_.js'; import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; @@ -152,6 +153,12 @@ const $apInboxLogsRepository: Provider = { inject: [DI.db], }; +const $skSharedAccessToken: Provider = { + provide: DI.sharedAccessToken, + useFactory: (db: DataSource) => db.getRepository(SkSharedAccessToken).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository), @@ -585,6 +592,7 @@ const $noteScheduleRepository: Provider = { $apContextRepository, $apFetchLogsRepository, $apInboxLogsRepository, + $skSharedAccessToken, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -667,6 +675,7 @@ const $noteScheduleRepository: Provider = { $apContextRepository, $apFetchLogsRepository, $apInboxLogsRepository, + $skSharedAccessToken, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/SkSharedAccessToken.ts b/packages/backend/src/models/SkSharedAccessToken.ts new file mode 100644 index 0000000000..f3db23d1b1 --- /dev/null +++ b/packages/backend/src/models/SkSharedAccessToken.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from '@/models/util/id.js'; +import { MiUser } from '@/models/User.js'; +import { MiAccessToken } from '@/models/AccessToken.js'; + +@Entity('shared_access_token') +export class SkSharedAccessToken { + @PrimaryColumn({ + ...id(), + comment: 'ID of the access token that is shared', + }) + public accessTokenId: string; + + @ManyToOne(() => MiAccessToken, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'accessTokenId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_shared_access_token_accessTokenId', + }) + public accessToken: MiAccessToken; + + @Index('IDX_shared_access_token_granteeId') + @Column({ + ...id(), + comment: 'ID of the user who is allowed to use this access token', + }) + public granteeId: string; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'granteeId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_shared_access_token_granteeId', + }) + public grantee?: MiUser; + + constructor(props?: Partial) { + if (props) { + Object.assign(this, props); + } + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 225e8ac025..c210b67b31 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -98,6 +98,7 @@ import { SkApInboxLog } from '@/models/SkApInboxLog.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApContext } from '@/models/SkApContext.js'; import { SkLatestNote } from '@/models/LatestNote.js'; +import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; @@ -168,6 +169,7 @@ export { SkApContext, SkApFetchLog, SkApInboxLog, + SkSharedAccessToken, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -327,4 +329,5 @@ export type ChatApprovalsRepository = Repository & MiRepository< export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; export type NoteEditRepository = Repository & MiRepository; -export type NoteScheduleRepository = Repository; +export type NoteScheduleRepository = Repository & MiRepository; +export type SharedAccessTokensRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 45caec54ce..7007d014a1 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -92,6 +92,7 @@ import { SkLatestNote } from '@/models/LatestNote.js'; import { SkApContext } from '@/models/SkApContext.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApInboxLog } from '@/models/SkApInboxLog.js'; +import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; pg.types.setTypeParser(20, Number); @@ -213,6 +214,7 @@ export const entities = [ SkApContext, SkApFetchLog, SkApInboxLog, + SkSharedAccessToken, MiAnnouncement, MiAnnouncementRead, MiMeta, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 90635906d6..54e8cfe841 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -298,6 +298,8 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes- export * as 'i/registry/set' from './endpoints/i/registry/set.js'; export * as 'i/revoke-token' from './endpoints/i/revoke-token.js'; export * as 'i/signin-history' from './endpoints/i/signin-history.js'; +export * as 'i/shared-access/list' from './endpoints/i/shared-access/list.js'; +export * as 'i/shared-access/login' from './endpoints/i/shared-access/login.js'; export * as 'i/unpin' from './endpoints/i/unpin.js'; export * as 'i/update' from './endpoints/i/update.js'; export * as 'i/update-email' from './endpoints/i/update-email.js'; diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts new file mode 100644 index 0000000000..185b77e1b1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { SharedAccessTokensRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +export const meta = { + requireCredential: true, + secure: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + user: { + ref: 'UserLite', + optional: false, nullable: false, + }, + permissions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + properties: { + userId: { + type: 'string', + optional: false, nullable: false, + }, + token: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + + // 2 calls per second + limit: { + duration: 1000, + max: 2, + }, +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.sharedAccessToken) + private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, + + private readonly userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const tokens = await this.sharedAccessTokensRepository.find({ + where: { granteeId: me.id }, + relations: { accessToken: true }, + }); + + const users = tokens.map(token => token.accessToken!.userId); + const packedUsers: Packed<'UserLite'>[] = await this.userEntityService.packMany(users, me); + const packedUserMap = new Map>(packedUsers.map(u => [u.id, u])); + + return await Promise.all(tokens.map(async token => ({ + id: token.accessTokenId, + permissions: token.accessToken!.permission, + user: packedUserMap.get(token.accessToken!.userId) as Packed<'UserLite'>, + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/login.ts b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts new file mode 100644 index 0000000000..1f195e0805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { SharedAccessTokensRepository } from '@/models/_.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + requireCredential: true, + secure: true, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + userId: { + type: 'string', + optional: false, nullable: false, + }, + token: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + + errors: { + noSuchAccess: { + message: 'No such access', + code: 'NO_SUCH_ACCESS', + id: 'd536e0f2-47fc-4d66-843c-f9276e98030f', + httpStatusCode: 403, + }, + }, + + // 2 calls per second + limit: { + duration: 1000, + max: 2, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + grantId: { type: 'string' }, + }, + required: ['grantId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.sharedAccessToken) + private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const token = await this.sharedAccessTokensRepository.findOne({ + where: { accessTokenId: ps.grantId, granteeId: me.id }, + relations: { accessToken: true }, + }); + + if (!token) { + throw new ApiError(meta.errors.noSuchAccess); + } + + return { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: token.accessToken!.token, + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + userId: token.accessToken!.userId, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index f962bd49f1..9435284d77 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -4,8 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; +import { ApiError } from '@/server/api/error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; +import type { AccessTokensRepository, SharedAccessTokensRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; @@ -29,6 +32,14 @@ export const meta = { }, }, + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a', + }, + }, + // 10 calls per 5 seconds limit: { duration: 1000 * 5, @@ -46,6 +57,9 @@ export const paramDef = { permission: { type: 'array', uniqueItems: true, items: { type: 'string', } }, + grantees: { type: 'array', uniqueItems: true, items: { + type: 'string', + } }, }, required: ['session', 'permission'], } as const; @@ -56,29 +70,56 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, + @Inject(DI.sharedAccessToken) + private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, + + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + @Inject(DI.db) + private readonly db: DataSource, + private idService: IdService, private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { + if (ps.grantees && ps.grantees.length > 0) { + const existingCount = await this.usersRepository.countBy({ id: In(ps.grantees) }); + if (existingCount !== ps.grantees.length) { + throw new ApiError(meta.errors.noSuchUser); + } + } + // Generate access token const accessToken = secureRndstr(32); const now = new Date(); + const accessTokenId = this.idService.gen(now.getTime()); - // Insert access token doc - await this.accessTokensRepository.insert({ - id: this.idService.gen(now.getTime()), - lastUsedAt: now, - session: ps.session, - userId: me.id, - token: accessToken, - hash: accessToken, - name: ps.name, - description: ps.description, - iconUrl: ps.iconUrl, - permission: ps.permission, + await this.db.transaction(async tem => { + // Insert access token doc + await this.accessTokensRepository.insert({ + id: accessTokenId, + lastUsedAt: now, + session: ps.session, + userId: me.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); + + // Insert shared access grants + if (ps.grantees && ps.grantees.length > 0) { + const grants = ps.grantees.map(granteeId => new SkSharedAccessToken({ accessTokenId, granteeId })); + await this.sharedAccessTokensRepository.insert(grants); + } }); + // TODO notify of access granted + // アクセストークンが生成されたことを通知 this.notificationService.createNotification(me.id, 'createToken', {}); diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 4ee951bbd7..c7843ddb7e 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -16,6 +16,7 @@ import { prefer } from '@/preferences.js'; import { store } from '@/store.js'; import { $i } from '@/i.js'; import { signout } from '@/signout.js'; +import * as os from '@/os'; type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; @@ -299,6 +300,15 @@ export async function openAccountMenu(opts: { } }); }, + }, { + text: i18n.ts.sharedAccess, + action: () => { + getAccountWithSharedAccessDialog().then((res) => { + if (res != null) { + os.success(); + } + }); + }, }], }, { type: 'link', @@ -324,6 +334,24 @@ export async function openAccountMenu(opts: { }); } +export function getAccountWithSharedAccessDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/SkSigninSharedAccessDialog.vue')), {}, { + done: async (res: { id: string, i: string }) => { + const user = await fetchAccount(res.i, res.id, true); + await addAccount(host, user, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { return new Promise((resolve) => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 42cb6f1e82..c5f2773876 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only -
{{ i18n.ts.permission }}
-
- {{ i18n.ts.disableAll }} - {{ i18n.ts.enableAll }} -
-
- {{ i18n.ts._permissions[kind] }} -
-
-
{{ i18n.ts.adminPermission }}
-
- {{ i18n.ts._permissions[kind] }} + + + + + +
+ {{ i18n.ts.disableAll }} + {{ i18n.ts.enableAll }}
-
+
+ {{ i18n.ts._permissions[kind] }} +
+
+
{{ i18n.ts.adminPermission }}
+
+ {{ i18n.ts._permissions[kind] }} +
+
+ + + + + + + + {{ i18n.ts.addGrantee }} + + +
+ + +
+
@@ -56,6 +75,10 @@ import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { iAmAdmin } from '@/i.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import * as os from '@/os'; +import { instance } from '@/instance'; const props = withDefaults(defineProps<{ title?: string | null; @@ -71,7 +94,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'closed'): void; - (ev: 'done', result: { name: string | null, permissions: string[] }): void; + (ev: 'done', result: { name: string | null, permissions: string[], grantees: string[] }): void; }>(); const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); @@ -81,6 +104,7 @@ const dialog = useTemplateRef('dialog'); const name = ref(props.initialName); const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); +const grantees = ref([]); if (props.initialPermissions) { for (const kind of props.initialPermissions) { @@ -105,6 +129,7 @@ function ok(): void { ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), ], + grantees: grantees.value.map(g => g.id), }); dialog.value?.close(); } @@ -130,6 +155,18 @@ function enableAll(): void { } } } + +async function addGrantee(): Promise { + const user = await os.selectUser({ + includeSelf: true, + localOnly: instance.noteSearchableScope === 'local', + }); + grantees.value.push(user); +} + +function removeGrantee(index: number) { + grantees.value.splice(index, 1); +} diff --git a/packages/frontend/src/components/SkSigninSharedAccessDialog.vue b/packages/frontend/src/components/SkSigninSharedAccessDialog.vue new file mode 100644 index 0000000000..16a2faf840 --- /dev/null +++ b/packages/frontend/src/components/SkSigninSharedAccessDialog.vue @@ -0,0 +1,126 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 2fd0a021da..bf8022c431 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -24,7 +24,7 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { $i } from '@/i.js'; -import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; +import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccountWithSharedAccessDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -59,9 +59,20 @@ function addAccount(ev: MouseEvent) { }, { text: i18n.ts.createAccount, action: () => { createAccount(); }, + }, { + text: i18n.ts.sharedAccount, + action: () => { addSharedAccount(); }, }], ev.currentTarget ?? ev.target); } +function addSharedAccount() { + getAccountWithSharedAccessDialog().then((res) => { + if (res != null) { + os.success(); + } + }); +} + function addExistingAccount() { getAccountWithSigninDialog().then((res) => { if (res != null) { diff --git a/packages/frontend/src/pages/settings/connect.vue b/packages/frontend/src/pages/settings/connect.vue index 280ee546dc..4c0156f779 100644 --- a/packages/frontend/src/pages/settings/connect.vue +++ b/packages/frontend/src/pages/settings/connect.vue @@ -84,11 +84,12 @@ const pagination = { function generateToken() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { done: async result => { - const { name, permissions } = result; + const { name, permissions, grantees } = result; const { token } = await misskeyApi('miauth/gen-token', { session: null, name: name, permission: permissions, + grantees: grantees, }); os.alert({ diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 040e9429f0..3f31fcd648 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1979,6 +1979,9 @@ declare namespace entities { IRegistryScopesWithDomainResponse, IRegistrySetRequest, IRevokeTokenRequest, + ISharedAccessListResponse, + ISharedAccessLoginRequest, + ISharedAccessLoginResponse, ISigninHistoryRequest, ISigninHistoryResponse, IUnpinRequest, @@ -2765,6 +2768,15 @@ type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['conten // @public (undocumented) function isAPIError(reason: Record): reason is APIError; +// @public (undocumented) +type ISharedAccessListResponse = operations['i___shared-access___list']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type ISharedAccessLoginRequest = operations['i___shared-access___login']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ISharedAccessLoginResponse = operations['i___shared-access___login']['responses']['200']['content']['application/json']; + // @public (undocumented) type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 0e061c8e06..ffe418d962 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3459,6 +3459,30 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 5bdaa58a6f..39f389f403 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -460,6 +460,9 @@ import type { IRegistryScopesWithDomainResponse, IRegistrySetRequest, IRevokeTokenRequest, + ISharedAccessListResponse, + ISharedAccessLoginRequest, + ISharedAccessLoginResponse, ISigninHistoryRequest, ISigninHistoryResponse, IUnpinRequest, @@ -980,6 +983,8 @@ export type Endpoints = { 'i/registry/scopes-with-domain': { req: EmptyRequest; res: IRegistryScopesWithDomainResponse }; 'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse }; 'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse }; + 'i/shared-access/list': { req: EmptyRequest; res: ISharedAccessListResponse }; + 'i/shared-access/login': { req: ISharedAccessLoginRequest; res: ISharedAccessLoginResponse }; 'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse }; 'i/unpin': { req: IUnpinRequest; res: IUnpinResponse }; 'i/update': { req: IUpdateRequest; res: IUpdateResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 4ad9c9afbb..08a8d09540 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -463,6 +463,9 @@ export type IRegistryRemoveRequest = operations['i___registry___remove']['reques export type IRegistryScopesWithDomainResponse = operations['i___registry___scopes-with-domain']['responses']['200']['content']['application/json']; export type IRegistrySetRequest = operations['i___registry___set']['requestBody']['content']['application/json']; export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json']; +export type ISharedAccessListResponse = operations['i___shared-access___list']['responses']['200']['content']['application/json']; +export type ISharedAccessLoginRequest = operations['i___shared-access___login']['requestBody']['content']['application/json']; +export type ISharedAccessLoginResponse = operations['i___shared-access___login']['responses']['200']['content']['application/json']; export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json']; export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json']; export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 03304eb69f..6d85ed4bde 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2988,6 +2988,26 @@ export type paths = { */ post: operations['i___revoke-token']; }; + '/i/shared-access/list': { + /** + * i/shared-access/list + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + post: operations['i___shared-access___list']; + }; + '/i/shared-access/login': { + /** + * i/shared-access/login + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + post: operations['i___shared-access___login']; + }; '/i/signin-history': { /** * i/signin-history @@ -25085,6 +25105,126 @@ export type operations = { }; }; }; + /** + * i/shared-access/list + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + 'i___shared-access___list': { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + id: string; + user: components['schemas']['UserLite']; + permissions: string[]; + }[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Too many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * i/shared-access/login + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + 'i___shared-access___login': { + requestBody: { + content: { + 'application/json': { + grantId: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + userId: string; + token: string; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Too many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * i/signin-history * @description No description provided. @@ -26238,6 +26378,7 @@ export type operations = { description?: string | null; iconUrl?: string | null; permission: string[]; + grantees?: string[]; }; }; }; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 4182d3f0b4..141966db98 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -678,3 +678,17 @@ clearCachedFilesOptions: customFontSize: "Custom font size" hideAds: "Hide ads" + +permissionsDescription: "Apps using this token will only have access to the functions listed below." + +sharedAccount: "Shared account" +sharedAccess: "Shared access" +sharedAccessDescription: "Any accounts listed here will be granted access to the token and may use it to access this account." +addGrantee: "Share access" +removeGrantee: "Remove access" +loginWithSharedAccess: "Login with shared access" +loginWithSharedAccessDescription: "Login with granted access to a shared account" +noSharedAccess: "You have not been granted shared access to any accounts" +expand: "Expand" +collapse: "Collapse" +permissions: "Permissions" From ec3947b992ab79e1a12de42d0247207da9c92a09 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 09:08:55 -0400 Subject: [PATCH 02/61] remove redundant async from i/shared-access/list --- .../backend/src/server/api/endpoints/i/shared-access/list.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts index 185b77e1b1..bf3d97bac0 100644 --- a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts +++ b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts @@ -80,11 +80,11 @@ export default class extends Endpoint { // eslint- const packedUsers: Packed<'UserLite'>[] = await this.userEntityService.packMany(users, me); const packedUserMap = new Map>(packedUsers.map(u => [u.id, u])); - return await Promise.all(tokens.map(async token => ({ + return tokens.map(token => ({ id: token.accessTokenId, permissions: token.accessToken!.permission, user: packedUserMap.get(token.accessToken!.userId) as Packed<'UserLite'>, - }))); + })); }); } } From a4c816d07cf80d9f2872f75690920e60117bc5a2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 09:15:46 -0400 Subject: [PATCH 03/61] move admin permissions to a separate folder --- locales/index.d.ts | 18 ++++++++------ .../src/components/MkTokenGenerateWindow.vue | 24 +++++++++++++++---- sharkey-locales/en-US.yml | 4 ++-- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index e80b71075a..782918e82b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13455,14 +13455,18 @@ export interface Locale extends ILocale { * Custom font size */ "customFontSize": string; - /** - * Hide ads - */ - "hideAds": string; - /** - * Apps using this token will only have access to the functions listed below. - */ + /** + * Hide ads + */ + "hideAds": string; + /** + * Apps using this token will have no API access except for the functions listed below. + */ "permissionsDescription": string; + /** + * Apps using this token will have no administrative access except for the functions enabled below. + */ + "adminPermissionsDescription": string; /** * Shared account */ diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index c5f2773876..ebfa3dd314 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -39,11 +39,19 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._permissions[kind] }}
-
-
{{ i18n.ts.adminPermission }}
-
- {{ i18n.ts._permissions[kind] }} -
+ + + + + + +
+ {{ i18n.ts.disableAll }} + {{ i18n.ts.enableAll }} +
+ +
+ {{ i18n.ts._permissions[kind] }}
@@ -138,6 +146,9 @@ function disableAll(): void { for (const p in permissionSwitches.value) { permissionSwitches.value[p] = false; } +} + +function disableAllAdmin(): void { if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { permissionSwitchesForAdmin.value[p] = false; @@ -149,6 +160,9 @@ function enableAll(): void { for (const p in permissionSwitches.value) { permissionSwitches.value[p] = true; } +} + +function enableAllAdmin(): void { if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { permissionSwitchesForAdmin.value[p] = true; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 141966db98..68bc7ca624 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -679,8 +679,8 @@ customFontSize: "Custom font size" hideAds: "Hide ads" -permissionsDescription: "Apps using this token will only have access to the functions listed below." - +permissionsDescription: "Apps using this token will have no API access except for the functions listed below." +adminPermissionsDescription: "Apps using this token will have no administrative access except for the functions enabled below." sharedAccount: "Shared account" sharedAccess: "Shared access" sharedAccessDescription: "Any accounts listed here will be granted access to the token and may use it to access this account." From 70b85e52154ec6972f2cb10b8c4b2b1b1f79914b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 09:46:31 -0400 Subject: [PATCH 04/61] allow tokens to limit a user's rank --- locales/index.d.ts | 22 +++++++++++++++ packages/backend/src/models/AccessToken.ts | 10 +++++++ .../server/api/endpoints/miauth/gen-token.ts | 2 ++ .../src/components/MkTokenGenerateWindow.vue | 28 +++++++++++++++++-- .../frontend/src/pages/settings/connect.vue | 3 +- packages/frontend/src/plugin.ts | 3 +- packages/misskey-js/src/autogen/types.ts | 2 ++ sharkey-locales/en-US.yml | 6 ++++ 8 files changed, 72 insertions(+), 4 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 782918e82b..6dfc76546a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13511,6 +13511,28 @@ export interface Locale extends ILocale { * Permissions */ "permissions": string; + /** + * Override rank + */ + "overrideRank": string; + /** + * Overrides the user rank (admin, moderator, or user) for apps using this token. + */ + "overrideRankDescription": string; + "_ranks": { + /** + * Admin + */ + "admin": string; + /** + * Moderator + */ + "mod": string; + /** + * User + */ + "user": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index 6f98c14ec1..ede40bde3a 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -8,6 +8,9 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiApp } from './App.js'; +export const accessTokenRanks = ['user', 'mod', 'admin'] as const; +export type AccessTokenRank = typeof accessTokenRanks[number]; + @Entity('access_token') export class MiAccessToken { @PrimaryColumn(id()) @@ -87,4 +90,11 @@ export class MiAccessToken { default: false, }) public fetched: boolean; + + @Column('enum', { + enum: accessTokenRanks, + nullable: true, + comment: 'Limits the user\' rank (user, moderator, or admin) when using this token. If null (default), then uses the user\'s actual rank.', + }) + public rank: AccessTokenRank | null; } diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index 9435284d77..f6f05045e3 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -60,6 +60,7 @@ export const paramDef = { grantees: { type: 'array', uniqueItems: true, items: { type: 'string', } }, + rank: { type: 'string', enum: ['admin', 'mod', 'user'], nullable: true }, }, required: ['session', 'permission'], } as const; @@ -109,6 +110,7 @@ export default class extends Endpoint { // eslint- description: ps.description, iconUrl: ps.iconUrl, permission: ps.permission, + rank: ps.rank, }); // Insert shared access grants diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index ebfa3dd314..9678982f99 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -28,6 +28,22 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + + + + + + + + + + + + @@ -82,9 +98,10 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { iAmAdmin } from '@/i.js'; +import { $i, iAmAdmin } from '@/i.js'; import MkFolder from '@/components/MkFolder.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkSelect from '@/components/MkSelect.vue'; import * as os from '@/os'; import { instance } from '@/instance'; @@ -102,7 +119,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'closed'): void; - (ev: 'done', result: { name: string | null, permissions: string[], grantees: string[] }): void; + (ev: 'done', result: { name: string | null, permissions: string[], grantees: string[], rank: string }): void; }>(); const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); @@ -113,6 +130,12 @@ const name = ref(props.initialName); const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); const grantees = ref([]); +const rank = ref<'admin' | 'mod' | 'user'>( + $i?.isAdmin + ? 'admin' + : $i?.isModerator + ? 'mod' + : 'user'); if (props.initialPermissions) { for (const kind of props.initialPermissions) { @@ -138,6 +161,7 @@ function ok(): void { ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), ], grantees: grantees.value.map(g => g.id), + rank: rank.value, }); dialog.value?.close(); } diff --git a/packages/frontend/src/pages/settings/connect.vue b/packages/frontend/src/pages/settings/connect.vue index 4c0156f779..8398592279 100644 --- a/packages/frontend/src/pages/settings/connect.vue +++ b/packages/frontend/src/pages/settings/connect.vue @@ -84,12 +84,13 @@ const pagination = { function generateToken() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { done: async result => { - const { name, permissions, grantees } = result; + const { name, permissions, grantees, rank } = result; const { token } = await misskeyApi('miauth/gen-token', { session: null, name: name, permission: permissions, grantees: grantees, + rank: rank, }); os.alert({ diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 9e9d492dc2..fa31a594ca 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -109,11 +109,12 @@ export async function authorizePlugin(plugin: Plugin) { initialPermissions: plugin.permissions, }, { done: async result => { - const { name, permissions } = result; + const { name, permissions, rank } = result; const { token } = await misskeyApi('miauth/gen-token', { session: null, name: name, permission: permissions, + rank: rank, }); res(token); }, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 6d85ed4bde..1a67ef2efa 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -26379,6 +26379,8 @@ export type operations = { iconUrl?: string | null; permission: string[]; grantees?: string[]; + /** @enum {string|null} */ + rank?: 'admin' | 'mod' | 'user'; }; }; }; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 68bc7ca624..003fc40f63 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -692,3 +692,9 @@ noSharedAccess: "You have not been granted shared access to any accounts" expand: "Expand" collapse: "Collapse" permissions: "Permissions" +overrideRank: "Override rank" +overrideRankDescription: "Overrides the user rank (admin, moderator, or user) for apps using this token." +_ranks: + admin: "Admin" + mod: "Moderator" + user: "User" From 1e10be3f11aadd200785a1278b50be6c37164471 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 09:46:54 -0400 Subject: [PATCH 05/61] hide shared access settings when authenticating a plugin --- packages/frontend/src/components/MkTokenGenerateWindow.vue | 2 ++ packages/frontend/src/plugin.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 9678982f99..b4e380cca9 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -110,11 +110,13 @@ const props = withDefaults(defineProps<{ information?: string | null; initialName?: string | null; initialPermissions?: (typeof Misskey.permissions)[number][] | null; + enableSharedAccess?: boolean; }>(), { title: null, information: null, initialName: null, initialPermissions: null, + enableSharedAccess: true, }); const emit = defineEmits<{ diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index fa31a594ca..e7e398aa89 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -107,6 +107,7 @@ export async function authorizePlugin(plugin: Plugin) { information: i18n.ts.pluginTokenRequestedDescription, initialName: plugin.name, initialPermissions: plugin.permissions, + enableSharedAccess: false, }, { done: async result => { const { name, permissions, rank } = result; From fe53c16b23080756ff2fc611b5a1c4ac52d962db Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 09:47:26 -0400 Subject: [PATCH 06/61] rename sharedAccessTokensRepository to match naming convention --- packages/backend/src/di-symbols.ts | 2 +- packages/backend/src/models/RepositoryModule.ts | 2 +- .../backend/src/server/api/endpoints/i/shared-access/list.ts | 2 +- .../backend/src/server/api/endpoints/i/shared-access/login.ts | 2 +- packages/backend/src/server/api/endpoints/miauth/gen-token.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 56e7abfd08..f839841128 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -96,6 +96,6 @@ export const DI = { bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), noteScheduleRepository: Symbol('noteScheduleRepository'), - sharedAccessToken: Symbol('sharedAccessToken'), + sharedAccessTokensRepository: Symbol('sharedAccessTokensRepository'), //#endregion }; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 25f48759e6..27a30f9d01 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -154,7 +154,7 @@ const $apInboxLogsRepository: Provider = { }; const $skSharedAccessToken: Provider = { - provide: DI.sharedAccessToken, + provide: DI.sharedAccessTokensRepository, useFactory: (db: DataSource) => db.getRepository(SkSharedAccessToken).extend(miRepository as MiRepository), inject: [DI.db], }; diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts index bf3d97bac0..4c2aa7755b 100644 --- a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts +++ b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts @@ -65,7 +65,7 @@ export const paramDef = {} as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.sharedAccessToken) + @Inject(DI.sharedAccessTokensRepository) private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, private readonly userEntityService: UserEntityService, diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/login.ts b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts index 1f195e0805..39fd1d8b28 100644 --- a/packages/backend/src/server/api/endpoints/i/shared-access/login.ts +++ b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts @@ -55,7 +55,7 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.sharedAccessToken) + @Inject(DI.sharedAccessTokensRepository) private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, ) { super(meta, paramDef, async (ps, me) => { diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index f6f05045e3..1eb1c15e39 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, - @Inject(DI.sharedAccessToken) + @Inject(DI.sharedAccessTokensRepository) private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, @Inject(DI.usersRepository) From 23e69eccbb6270ee236051e9d00d07eb7a4fa895 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 21 Jun 2025 10:04:41 -0400 Subject: [PATCH 07/61] show grantee and rank info in tokens list --- locales/index.d.ts | 8 +++- .../src/server/api/endpoints/i/apps.ts | 47 +++++++++++++++---- packages/frontend/src/pages/settings/apps.vue | 37 +++++++++++++-- packages/misskey-js/src/autogen/types.ts | 7 ++- sharkey-locales/en-US.yml | 5 +- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 6dfc76546a..2d75a86ca6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13512,13 +13512,17 @@ export interface Locale extends ILocale { */ "permissions": string; /** - * Override rank + * Limit rank */ "overrideRank": string; /** - * Overrides the user rank (admin, moderator, or user) for apps using this token. + * Limits the user rank (admin, moderator, or user) for apps using this token. */ "overrideRankDescription": string; + /** + * Rank + */ + "rank": string; "_ranks": { /** * Admin diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index f290ff6844..c33174ed48 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; +import type { AccessTokensRepository, SharedAccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; export const meta = { requireCredential: true, @@ -46,6 +47,19 @@ export const meta = { type: 'string', }, }, + grantees: { + type: 'array', + optional: false, + items: { + ref: 'UserLite', + }, + }, + rank: { + type: 'string', + optional: false, + nullable: true, + enum: ['admin', 'mod', 'user'], + }, }, }, }, @@ -71,6 +85,10 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, + @Inject(DI.sharedAccessTokensRepository) + private readonly sharedAccessTokenRepository: SharedAccessTokensRepository, + + private readonly userEntityService: UserEntityService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { @@ -88,13 +106,26 @@ export default class extends Endpoint { // eslint- const tokens = await query.getMany(); - return await Promise.all(tokens.map(token => ({ - id: token.id, - name: token.name ?? token.app?.name, - createdAt: this.idService.parse(token.id).date.toISOString(), - lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.app ? token.app.permission : token.permission, - }))); + return await Promise.all(tokens.map(async token => { + // TODO inline this table into a column w/ GIN index + const sharedTokens = await this.sharedAccessTokenRepository.find({ + where: { accessTokenId: token.id }, + relations: { grantee: true }, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const grantees = await this.userEntityService.packMany(sharedTokens.map(t => t.grantee!), me); + + return { + id: token.id, + name: token.name ?? token.app?.name, + createdAt: this.idService.parse(token.id).date.toISOString(), + lastUsedAt: token.lastUsedAt?.toISOString(), + permission: token.app ? token.app.permission : token.permission, + rank: token.rank, + grantees, + }; + })); }); } } diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 33c17e5d7f..8521a35103 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -32,14 +32,31 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + - + - +
    -
  • {{ i18n.ts._permissions[p] }}
  • +
  • {{ i18n.ts._permissions[p] }}
+ + + +
    +
  • {{ i18n.ts._permissions[p] }}
  • +
+
+ + + + + +
@@ -50,7 +67,6 @@ SPDX-License-Identifier: AGPL-3.0-only