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"