merge: Implement shared access for accounts (!1140)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1140 Closes #657 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
fd0568a7d4
56 changed files with 1600 additions and 109 deletions
166
locales/index.d.ts
vendored
166
locales/index.d.ts
vendored
|
|
@ -9244,6 +9244,14 @@ export interface Locale extends ILocale {
|
|||
* Compose or delete scheduled notes
|
||||
*/
|
||||
"write:notes-schedule": string;
|
||||
/**
|
||||
* Read abuse report notification recipients
|
||||
*/
|
||||
"read:admin:abuse-report:notification-recipient": string;
|
||||
/**
|
||||
* Edit abuse report notification recipients
|
||||
*/
|
||||
"write:admin:abuse-report:notification-recipient": string;
|
||||
};
|
||||
"_auth": {
|
||||
/**
|
||||
|
|
@ -10434,6 +10442,18 @@ export interface Locale extends ILocale {
|
|||
* Import of {x} has been completed
|
||||
*/
|
||||
"importOfXCompleted": ParameterizedString<"x">;
|
||||
/**
|
||||
* Shared access granted
|
||||
*/
|
||||
"sharedAccessGranted": string;
|
||||
/**
|
||||
* Shared access revoked
|
||||
*/
|
||||
"sharedAccessRevoked": string;
|
||||
/**
|
||||
* Shared access login
|
||||
*/
|
||||
"sharedAccessLogin": string;
|
||||
};
|
||||
"_deck": {
|
||||
/**
|
||||
|
|
@ -13467,6 +13487,152 @@ export interface Locale extends ILocale {
|
|||
* 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
|
||||
*/
|
||||
"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;
|
||||
/**
|
||||
* Shared access allows another user to access your account without using your password. You may select exactly which features and data are available to guest users.
|
||||
*/
|
||||
"sharedAccessDescription2": 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;
|
||||
/**
|
||||
* Limit rank
|
||||
*/
|
||||
"overrideRank": string;
|
||||
/**
|
||||
* Limits the user rank (admin, moderator, or user) for apps using this token.
|
||||
*/
|
||||
"overrideRankDescription": string;
|
||||
/**
|
||||
* Rank
|
||||
*/
|
||||
"rank": string;
|
||||
"_ranks": {
|
||||
/**
|
||||
* Admin
|
||||
*/
|
||||
"admin": string;
|
||||
/**
|
||||
* Moderator
|
||||
*/
|
||||
"mod": string;
|
||||
/**
|
||||
* User
|
||||
*/
|
||||
"user": string;
|
||||
/**
|
||||
* default
|
||||
*/
|
||||
"default": string;
|
||||
};
|
||||
/**
|
||||
* Permissions: {num}
|
||||
*/
|
||||
"permissionsLabel": ParameterizedString<"num">;
|
||||
/**
|
||||
* You have been granted shared access to {target} with {rank} rank and {perms} permissions.
|
||||
*/
|
||||
"sharedAccessGranted": ParameterizedString<"target" | "rank" | "perms">;
|
||||
/**
|
||||
* Shared access to {target} has been revoked.
|
||||
*/
|
||||
"sharedAccessRevoked": ParameterizedString<"target">;
|
||||
/**
|
||||
* {target} logged in via shared access.
|
||||
*/
|
||||
"sharedAccessLogin": ParameterizedString<"target">;
|
||||
/**
|
||||
* Unique name to record the purpose of this access token
|
||||
*/
|
||||
"accessTokenNameDescription": string;
|
||||
/**
|
||||
* Are you sure you want to revoke this token?
|
||||
*/
|
||||
"confirmRevokeToken": string;
|
||||
/**
|
||||
* Are you sure you want to revoke this token? {num} shared other users will lose shared access.
|
||||
*/
|
||||
"confirmRevokeSharedToken": ParameterizedString<"num">;
|
||||
/**
|
||||
* Grant shared access
|
||||
*/
|
||||
"grantSharedAccessButton": string;
|
||||
/**
|
||||
* No shared access listed
|
||||
*/
|
||||
"grantSharedAccessNoSelection": string;
|
||||
/**
|
||||
* No shared access users were selected. Please add at least one user in the "shared access" section.
|
||||
*/
|
||||
"grantSharedAccessNoSelection2": string;
|
||||
/**
|
||||
* Shared access granted
|
||||
*/
|
||||
"grantSharedAccessSuccess": string;
|
||||
/**
|
||||
* Shared access has been granted to {num} users.
|
||||
*/
|
||||
"grantSharedAccessSuccess2": ParameterizedString<"num">;
|
||||
/**
|
||||
* Are you sure you want to create a token with no permissions?
|
||||
*/
|
||||
"tokenHasNoPermissionsConfirm": string;
|
||||
/**
|
||||
* Enable all read-only permissions
|
||||
*/
|
||||
"enableAllRead": string;
|
||||
/**
|
||||
* Enable all write/edit permissions
|
||||
*/
|
||||
"enableAllWrite": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RemoveSharedAccessToken1750525552482 {
|
||||
name = 'RemoveSharedAccessToken1750525552482'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Drop old table
|
||||
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"`);
|
||||
|
||||
// Create new column
|
||||
await queryRunner.query(`ALTER TABLE "access_token" ADD "granteeIds" character varying(32) array NOT NULL DEFAULT '{}'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "access_token"."granteeIds" IS 'IDs of other users who are permitted to access and use this token.'`);
|
||||
|
||||
// Create custom index
|
||||
await queryRunner.query(`CREATE INDEX "IDX_access_token_granteeIds" ON "access_token" USING GIN ("granteeIds" array_ops)`)
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
// Drop custom index
|
||||
await queryRunner.query(`DROP INDEX "IDX_access_token_granteeIds"`);
|
||||
|
||||
// Drop new column
|
||||
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "granteeIds"`);
|
||||
|
||||
// Create old table
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddAccessTokenRank1750525832125 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TYPE "public"."access_token_rank_enum" AS ENUM('user', 'mod', 'admin')`);
|
||||
await queryRunner.query(`ALTER TABLE "access_token" ADD "rank" "public"."access_token_rank_enum"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "access_token"."rank" IS 'Limits the user'' rank (user, moderator, or admin) when using this token. If null (default), then uses the user''s actual rank.'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "access_token"."rank" IS 'Limits the user'' rank (user, moderator, or admin) when using this token. If null (default), then uses the user''s actual rank.'`);
|
||||
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "rank"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."access_token_rank_enum"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -342,11 +342,11 @@ export class ChatService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) {
|
||||
if (await this.isRoomMember(room, meId)) {
|
||||
public async hasPermissionToViewRoomTimeline(me: MiUser, room: MiChatRoom) {
|
||||
if (await this.isRoomMember(room, me.id)) {
|
||||
return true;
|
||||
} else {
|
||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
||||
const iAmModerator = await this.roleService.isModerator(me);
|
||||
if (iAmModerator) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -563,12 +563,12 @@ export class ChatService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) {
|
||||
if (room.ownerId === meId) {
|
||||
public async hasPermissionToDeleteRoom(me: MiUser, room: MiChatRoom) {
|
||||
if (room.ownerId === me.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
||||
const iAmModerator = await this.roleService.isModerator(me);
|
||||
if (iAmModerator) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import { getCallerId } from '@/misc/attach-caller-id.js';
|
||||
|
||||
export type RolePolicies = {
|
||||
gtlAvailable: boolean;
|
||||
|
|
@ -414,7 +415,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
|
||||
let allRoles = [...assignedRoles, ...matchedCondRoles];
|
||||
|
||||
// Check for dropped token permissions
|
||||
const rank = user ? getCallerId(user)?.accessToken?.rank : null;
|
||||
if (rank != null) {
|
||||
// Copy roles, since they come from a cache
|
||||
allRoles = allRoles.map(role => ({
|
||||
...role,
|
||||
isModerator: role.isModerator && (rank === 'admin' || rank === 'mod'),
|
||||
isAdministrator: role.isAdministrator && rank === 'admin',
|
||||
}));
|
||||
}
|
||||
|
||||
return allRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -514,12 +529,22 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
@bindThis
|
||||
public async isModerator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||
if (user == null) return false;
|
||||
|
||||
// Check for dropped token permissions
|
||||
const rank = getCallerId(user)?.accessToken?.rank;
|
||||
if (rank != null && rank !== 'admin' && rank !== 'mod') return false;
|
||||
|
||||
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isAdministrator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||
if (user == null) return false;
|
||||
|
||||
// Check for dropped token permissions
|
||||
const rank = getCallerId(user)?.accessToken?.rank;
|
||||
if (rank != null && rank !== 'admin') return false;
|
||||
|
||||
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class ClipEntityService {
|
|||
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
||||
lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null,
|
||||
userId: clip.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId),
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId, me),
|
||||
name: clip.name,
|
||||
description: clip.description,
|
||||
isPublic: clip.isPublic,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { ModuleRef } from '@nestjs/core';
|
||||
import { In, EntityNotFoundError } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js';
|
||||
import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository, AccessTokensRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
|
|
@ -21,7 +21,7 @@ import type { OnModuleInit } from '@nestjs/common';
|
|||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted']);
|
||||
|
||||
function undefOnMissing<T>(packPromise: Promise<T>): Promise<T | undefined> {
|
||||
return packPromise.catch(err => {
|
||||
|
|
@ -49,6 +49,9 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.accessTokensRepository)
|
||||
private readonly accessTokensRepository: AccessTokensRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
|
@ -199,6 +202,10 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
header: notification.customHeader,
|
||||
icon: notification.customIcon,
|
||||
} : {}),
|
||||
...(notification.type === 'sharedAccessGranted' ? {
|
||||
permCount: notification.permCount,
|
||||
rank: notification.rank,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import _Ajv from 'ajv';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { In } from 'typeorm';
|
||||
|
|
@ -54,6 +55,7 @@ import { ChatService } from '@/core/ChatService.js';
|
|||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { CacheService } from '@/core/CacheService.js';
|
||||
import { getCallerId } from '@/misc/attach-caller-id.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
|
|
@ -432,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
userMemos?: Map<MiUser['id'], string | null>,
|
||||
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
|
||||
iAmModerator?: boolean,
|
||||
iAmAdmin?: boolean,
|
||||
userIdsByUri?: Map<string, string>,
|
||||
instances?: Map<string, MiInstance | null>,
|
||||
securityKeyCounts?: Map<string, number>,
|
||||
|
|
@ -477,7 +480,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
const isDetailed = opts.schema !== 'UserLite';
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId === user.id;
|
||||
const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false);
|
||||
const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me) : false);
|
||||
const iAmAdmin = opts.iAmAdmin ?? (me ? await this.roleService.isAdministrator(me) : false);
|
||||
|
||||
const profile = isDetailed
|
||||
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||
|
|
@ -529,8 +533,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
|
||||
null;
|
||||
|
||||
const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null;
|
||||
const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null;
|
||||
const unreadAnnouncements = isMe && isDetailed ?
|
||||
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
|
||||
createdAt: this.idService.parse(announcement.id).date.toISOString(),
|
||||
|
|
@ -663,8 +665,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
bannerId: user.bannerId,
|
||||
backgroundId: user.backgroundId,
|
||||
followedMessage: profile!.followedMessage,
|
||||
isModerator: isModerator,
|
||||
isAdmin: isAdmin,
|
||||
isModerator: iAmModerator,
|
||||
isAdmin: iAmAdmin,
|
||||
isSystem: isSystemAccount(user),
|
||||
injectFeaturedNote: profile!.injectFeaturedNote,
|
||||
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
|
||||
|
|
@ -699,6 +701,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
achievements: profile!.achievements,
|
||||
loggedInDays: profile!.loggedInDates.length,
|
||||
policies: fetchPolicies(),
|
||||
permissions: this.getPermissions(user, iAmModerator, iAmAdmin),
|
||||
defaultCW: profile!.defaultCW,
|
||||
defaultCWPriority: profile!.defaultCWPriority,
|
||||
allowUnsignedFetch: user.allowUnsignedFetch,
|
||||
|
|
@ -767,7 +770,10 @@ export class UserEntityService implements OnModuleInit {
|
|||
}
|
||||
const _userIds = _users.map(u => u.id);
|
||||
|
||||
const iAmModerator = await this.roleService.isModerator(me as MiUser);
|
||||
// Sync with ApiCallService
|
||||
const iAmAdmin = me ? await this.roleService.isAdministrator(me) : false;
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me) : false;
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const isDetailed = options && options.schema !== 'UserLite';
|
||||
const isDetailedAndMod = isDetailed && iAmModerator;
|
||||
|
|
@ -864,6 +870,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
userMemos: userMemos,
|
||||
pinNotes: pinNotes,
|
||||
iAmModerator,
|
||||
iAmAdmin,
|
||||
userIdsByUri,
|
||||
instances,
|
||||
securityKeyCounts,
|
||||
|
|
@ -872,4 +879,16 @@ export class UserEntityService implements OnModuleInit {
|
|||
)),
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getPermissions(user: MiUser, isModerator: boolean, isAdmin: boolean): readonly string[] {
|
||||
const token = getCallerId(user);
|
||||
let permissions = token?.accessToken?.permission ?? Misskey.permissions;
|
||||
|
||||
if (!isModerator && !isAdmin) {
|
||||
permissions = permissions.filter(perm => !perm.startsWith('read:admin') && !perm.startsWith('write:admin'));
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
packages/backend/src/misc/attach-caller-id.ts
Normal file
42
packages/backend/src/misc/attach-caller-id.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { MiAccessToken } from '@/models/AccessToken.js';
|
||||
|
||||
const callerIdSymbol = Symbol('callerId');
|
||||
|
||||
/**
|
||||
* Client metadata associated with an object (typically an instance of MiUser).
|
||||
*/
|
||||
export interface CallerId {
|
||||
/**
|
||||
* Client's access token, or null if no token was used.
|
||||
*/
|
||||
accessToken?: MiAccessToken | null;
|
||||
}
|
||||
|
||||
interface ObjectWithCallerId {
|
||||
[callerIdSymbol]?: CallerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches client metadata to an object.
|
||||
* Calling this repeatedly will overwrite the previous value.
|
||||
* Pass undefined to erase the attached data.
|
||||
* @param target Object to attach to (typically an instance of MiUser).
|
||||
* @param callerId Data to attach.
|
||||
*/
|
||||
export function attachCallerId(target: object, callerId: CallerId | undefined): void {
|
||||
(target as ObjectWithCallerId)[callerIdSymbol] = callerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches client metadata from an object.
|
||||
* Returns undefined if no metadata is attached.
|
||||
* @param target Object to fetch from.
|
||||
*/
|
||||
export function getCallerId(target: object): CallerId | undefined {
|
||||
return (target as ObjectWithCallerId)[callerIdSymbol];
|
||||
}
|
||||
|
|
@ -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,25 @@ 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;
|
||||
|
||||
@Index('IDX_access_token_granteeIds', { synchronize: false })
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}',
|
||||
comment: 'IDs of other users who are permitted to access and use this token.',
|
||||
})
|
||||
public granteeIds: string[];
|
||||
|
||||
public constructor(props?: Partial<MiAccessToken>) {
|
||||
if (props) {
|
||||
Object.assign(this, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,23 @@ export type MiNotification = {
|
|||
id: string;
|
||||
createdAt: string;
|
||||
noteId: MiNote['id'];
|
||||
} | {
|
||||
type: 'sharedAccessGranted';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
notifierId: MiUser['id'];
|
||||
permCount: number;
|
||||
rank: 'user' | 'mod' | 'admin' | null;
|
||||
} | {
|
||||
type: 'sharedAccessRevoked';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
notifierId: MiUser['id'];
|
||||
} | {
|
||||
type: 'sharedAccessLogin';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
notifierId: MiUser['id'];
|
||||
};
|
||||
|
||||
export type MiGroupedNotification = MiNotification | {
|
||||
|
|
|
|||
|
|
@ -327,4 +327,4 @@ export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<
|
|||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
|
||||
export type NoteScheduleRepository = Repository<MiNoteSchedule>;
|
||||
export type NoteScheduleRepository = Repository<MiNoteSchedule> & MiRepository<MiNoteSchedule>;
|
||||
|
|
|
|||
|
|
@ -460,6 +460,75 @@ export const packedNotificationSchema = {
|
|||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['sharedAccessGranted'],
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
permCount: {
|
||||
type: 'number',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
rank: {
|
||||
type: 'string',
|
||||
enum: ['admin', 'mod', 'user'],
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['sharedAccessRevoked'],
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['sharedAccessLogin'],
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -739,6 +739,13 @@ export const packedMeDetailedOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
ref: 'RolePolicies',
|
||||
},
|
||||
permissions: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ import type { Config } from '@/config.js';
|
|||
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { renderFullError } from '@/misc/render-full-error.js';
|
||||
import { ApiError } from './error.js';
|
||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
||||
import { renderFullError } from '@/misc/render-full-error.js';
|
||||
|
||||
const accessDenied = {
|
||||
message: 'Access denied.',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { MiApp } from '@/models/App.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isNativeUserToken } from '@/misc/token.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { attachCallerId } from '@/misc/attach-caller-id.js';
|
||||
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message: string) {
|
||||
|
|
@ -62,6 +63,9 @@ export class AuthenticateService implements OnApplicationShutdown {
|
|||
}, {
|
||||
token: token, // miauth
|
||||
}],
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (accessToken == null) {
|
||||
|
|
@ -72,10 +76,11 @@ export class AuthenticateService implements OnApplicationShutdown {
|
|||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId,
|
||||
() => this.usersRepository.findOneBy({
|
||||
id: accessToken.userId,
|
||||
}) as Promise<MiLocalUser>);
|
||||
// Loaded by relation above
|
||||
const user = accessToken.user as MiLocalUser;
|
||||
|
||||
// Attach token to user - this will be read by RoleService to drop admin/moderator permissions.
|
||||
attachCallerId(user, { accessToken });
|
||||
|
||||
if (accessToken.appId) {
|
||||
const app = await this.appCache.fetch(accessToken.appId,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.userNotFound);
|
||||
}
|
||||
|
||||
const res = await this.userEntityService.pack(profile.user!, null, {
|
||||
const res = await this.userEntityService.pack(profile.user!, me, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
secret: secret,
|
||||
});
|
||||
|
||||
return await this.appEntityService.pack(app, null, {
|
||||
return await this.appEntityService.pack(app, me, {
|
||||
detail: true,
|
||||
includeSecret: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
return {
|
||||
accessToken: accessToken.token,
|
||||
user: await this.userEntityService.pack(session.userId, null, {
|
||||
user: await this.userEntityService.pack(session.userId, me, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const records = await this.bubbleGameRecordsRepository.find({
|
||||
where: {
|
||||
gameMode: ps.gameMode,
|
||||
|
|
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
relations: ['user'],
|
||||
});
|
||||
|
||||
const users = await this.userEntityService.packMany(records.map(r => r.user!), null);
|
||||
const users = await this.userEntityService.packMany(records.map(r => r.user!), me);
|
||||
|
||||
return records.map(r => ({
|
||||
id: r.id,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) {
|
||||
if (!await this.chatService.hasPermissionToViewRoomTimeline(me, room)) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) {
|
||||
if (!await this.chatService.hasPermissionToDeleteRoom(me, room)) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
visibility: ps.visibility,
|
||||
});
|
||||
|
||||
return await this.flashEntityService.pack(flash);
|
||||
return await this.flashEntityService.pack(flash, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs);
|
||||
return await this.flashEntityService.packMany(flashs, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (userProfile == null) {
|
||||
|
|
@ -80,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
|
||||
}
|
||||
|
||||
return await this.userEntityService.pack(userProfile.user!, userProfile.user!, {
|
||||
return await this.userEntityService.pack(user, user, {
|
||||
schema: 'MeDetailed',
|
||||
includeSecrets: isSecure,
|
||||
userProfile,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import type { AccessTokensRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
|
@ -46,6 +49,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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -61,6 +77,10 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+lastUsedAt', '-lastUsedAt'] },
|
||||
onlySharedAccess: { type: 'boolean' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
@ -71,11 +91,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.accessTokensRepository)
|
||||
private accessTokensRepository: AccessTokensRepository,
|
||||
|
||||
private readonly userEntityService: UserEntityService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.accessTokensRepository.createQueryBuilder('token')
|
||||
const query = this.queryService.makePaginationQuery(this.accessTokensRepository.createQueryBuilder('token'), ps.sinceId, ps.untilId)
|
||||
.where('token.userId = :userId', { userId: me.id })
|
||||
.limit(ps.limit)
|
||||
.leftJoinAndSelect('token.app', 'app');
|
||||
|
||||
switch (ps.sort) {
|
||||
|
|
@ -86,15 +110,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
default: query.orderBy('token.id', 'ASC'); break;
|
||||
}
|
||||
|
||||
if (ps.onlySharedAccess) {
|
||||
query.andWhere('token.granteeIds != \'{}\'');
|
||||
}
|
||||
|
||||
const tokens = await query.getMany();
|
||||
|
||||
return await Promise.all(tokens.map(token => ({
|
||||
const users = await this.cacheService.getUsers(tokens.flatMap(token => token.granteeIds));
|
||||
const packedUsers = await this.userEntityService.packMany(Array.from(users.values()), me);
|
||||
const packedUserMap = new Map(packedUsers.map(u => [u.id, u]));
|
||||
|
||||
return 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,
|
||||
})));
|
||||
rank: token.rank,
|
||||
grantees: token.granteeIds
|
||||
.map(id => packedUserMap.get(id))
|
||||
.filter(user => user != null),
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { AccessTokensRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
|
@ -37,21 +38,31 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.accessTokensRepository)
|
||||
private accessTokensRepository: AccessTokensRepository,
|
||||
|
||||
private readonly notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.tokenId) {
|
||||
const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
|
||||
const tokenExist = await this.accessTokensRepository.findOne({ where: { id: ps.tokenId } });
|
||||
|
||||
if (tokenExist) {
|
||||
for (const granteeId of tokenExist.granteeIds) {
|
||||
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
|
||||
}
|
||||
|
||||
await this.accessTokensRepository.delete({
|
||||
id: ps.tokenId,
|
||||
userId: me.id,
|
||||
});
|
||||
}
|
||||
} else if (ps.token) {
|
||||
const tokenExist = await this.accessTokensRepository.exists({ where: { token: ps.token } });
|
||||
const tokenExist = await this.accessTokensRepository.findOne({ where: { token: ps.token } });
|
||||
|
||||
if (tokenExist) {
|
||||
for (const granteeId of tokenExist.granteeIds) {
|
||||
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
|
||||
}
|
||||
|
||||
await this.accessTokensRepository.delete({
|
||||
token: ps.token,
|
||||
userId: me.id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 { AccessTokensRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
rank: {
|
||||
type: 'string',
|
||||
enum: ['admin', 'mod', 'user'],
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
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 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.accessTokensRepository)
|
||||
private readonly accessTokensRepository: AccessTokensRepository,
|
||||
|
||||
private readonly userEntityService: UserEntityService,
|
||||
private readonly queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const tokens = await this.queryService.makePaginationQuery(this.accessTokensRepository.createQueryBuilder('token'), ps.sinceId, ps.untilId)
|
||||
.where(':meIdAsList <@ token.granteeIds', { meIdAsList: [me.id] })
|
||||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
||||
const userIds = tokens.map(token => token.userId);
|
||||
const packedUsers = await this.userEntityService.packMany(userIds, me);
|
||||
const packedUserMap = new Map(packedUsers.map(u => [u.id, u]));
|
||||
|
||||
return tokens.map(token => ({
|
||||
id: token.id,
|
||||
permissions: token.permission,
|
||||
user: packedUserMap.get(token.userId),
|
||||
rank: token.rank,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { AccessTokensRepository } from '@/models/_.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { NotificationService } from '@/core/NotificationService.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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.accessTokensRepository)
|
||||
private readonly accessTokensRepository: AccessTokensRepository,
|
||||
|
||||
private readonly notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const token = await this.accessTokensRepository.findOneBy({ id: ps.grantId });
|
||||
|
||||
if (!token) {
|
||||
throw new ApiError(meta.errors.noSuchAccess);
|
||||
}
|
||||
|
||||
if (!token.granteeIds.includes(me.id)) {
|
||||
throw new ApiError(meta.errors.noSuchAccess);
|
||||
}
|
||||
|
||||
this.notificationService.createNotification(token.userId, 'sharedAccessLogin', {}, me.id);
|
||||
|
||||
return {
|
||||
token: token.token,
|
||||
userId: token.userId,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { AccessTokensRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['auth'],
|
||||
|
|
@ -29,6 +31,19 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchUser: {
|
||||
message: 'No such user.',
|
||||
code: 'NO_SUCH_USER',
|
||||
id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a',
|
||||
},
|
||||
mustBeLocal: {
|
||||
message: 'Grantee must be local',
|
||||
code: 'MUST_BE_LOCAL',
|
||||
id: '403c73e5-6f03-41b4-9394-ac128947f7ae',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
|
|
@ -46,6 +61,10 @@ export const paramDef = {
|
|||
permission: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string',
|
||||
} },
|
||||
grantees: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string',
|
||||
} },
|
||||
rank: { type: 'string', enum: ['admin', 'mod', 'user'], nullable: true },
|
||||
},
|
||||
required: ['session', 'permission'],
|
||||
} as const;
|
||||
|
|
@ -58,8 +77,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private idService: IdService,
|
||||
private notificationService: NotificationService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Validate grantees
|
||||
if (ps.grantees && ps.grantees.length > 0) {
|
||||
const grantees = await this.cacheService.getUsers(ps.grantees);
|
||||
|
||||
if (grantees.size !== ps.grantees.length) {
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
}
|
||||
|
||||
for (const grantee of grantees.values()) {
|
||||
if (grantee.host != null) {
|
||||
throw new ApiError(meta.errors.mustBeLocal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
const accessToken = secureRndstr(32);
|
||||
|
||||
|
|
@ -77,8 +112,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
description: ps.description,
|
||||
iconUrl: ps.iconUrl,
|
||||
permission: ps.permission,
|
||||
rank: ps.rank,
|
||||
granteeIds: ps.grantees,
|
||||
});
|
||||
|
||||
if (ps.grantees) {
|
||||
for (const granteeId of ps.grantees) {
|
||||
this.notificationService.createNotification(granteeId, 'sharedAccessGranted', { permCount: ps.permission.length, rank: ps.rank ?? null }, me.id);
|
||||
}
|
||||
}
|
||||
|
||||
// アクセストークンが生成されたことを通知
|
||||
this.notificationService.createNotification(me.id, 'createToken', {});
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
const policies = await this.roleService.getUserPolicies(me);
|
||||
if (!policies.btlAvailable) {
|
||||
throw new ApiError(meta.errors.btlDisabled);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,9 +69,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// TODO inline this into the above query
|
||||
for (const note of renotes) {
|
||||
if (ps.quote) {
|
||||
if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
|
||||
if (note.text) this.noteDeleteService.delete(me, note, false);
|
||||
} else {
|
||||
if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
|
||||
if (!note.text) this.noteDeleteService.delete(me, note, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
font: ps.font,
|
||||
}));
|
||||
|
||||
return await this.pageEntityService.pack(page);
|
||||
return await this.pageEntityService.pack(page, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs);
|
||||
return await this.flashEntityService.packMany(flashs, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const currentCount = await this.userListsRepository.countBy({
|
||||
userId: me.id,
|
||||
});
|
||||
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) {
|
||||
if (currentCount >= (await this.roleService.getUserPolicies(me)).userListLimit) {
|
||||
throw new ApiError(meta.errors.tooManyUserLists);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await this.pageEntityService.packMany(pages);
|
||||
return await this.pageEntityService.packMany(pages, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export const notificationTypes = [
|
|||
'scheduledNotePosted',
|
||||
'app',
|
||||
'test',
|
||||
'sharedAccessGranted',
|
||||
'sharedAccessRevoked',
|
||||
'sharedAccessLogin',
|
||||
] as const;
|
||||
|
||||
export const groupedNotificationTypes = [
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -39,7 +40,18 @@ export async function getAccounts(): Promise<{
|
|||
}
|
||||
|
||||
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
|
||||
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
|
||||
// Check for duplicate accounts
|
||||
if (prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
|
||||
if (store.s.accountTokens[host + '/' + user.id] !== token) {
|
||||
// Replace account if the token changed
|
||||
await removeAccount(host, user.id);
|
||||
} else {
|
||||
console.debug(`Not adding account ${host}/${user.id}: already logged in with same token.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
|
||||
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
|
||||
|
|
@ -299,6 +311,15 @@ export async function openAccountMenu(opts: {
|
|||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.sharedAccount,
|
||||
action: () => {
|
||||
getAccountWithSharedAccessDialog().then((res) => {
|
||||
if (res != null) {
|
||||
os.success();
|
||||
}
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
|
|
@ -324,6 +345,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')), {}, {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
|
||||
[$style.t_importCompleted]: notification.type === 'importCompleted',
|
||||
[$style.t_login]: notification.type === 'login',
|
||||
[$style.t_login]: ['sharedAccessGranted', 'sharedAccessRevoked', 'sharedAccessLogin'].includes(notification.type),
|
||||
[$style.t_createToken]: notification.type === 'createToken',
|
||||
[$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived',
|
||||
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
|
||||
|
|
@ -34,7 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
|
||||
[$style.t_pollEnded]: notification.type === 'scheduledNotePosted',
|
||||
}]"
|
||||
> <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
|
||||
>
|
||||
<!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
|
||||
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
|
||||
<i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i>
|
||||
<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i>
|
||||
|
|
@ -56,6 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
|
||||
<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
|
||||
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-calendar-event"></i>
|
||||
<i v-else-if="notification.type === 'sharedAccessGranted'" class="ph-door-open ph-bold pg-lg"></i>
|
||||
<i v-else-if="notification.type === 'sharedAccessRevoked'" class="ph-lock ph-bold pg-lg"></i>
|
||||
<i v-else-if="notification.type === 'sharedAccessLogin'" class="ph-sign-in ph-bold pg-lg"></i>
|
||||
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
|
||||
<MkReactionIcon
|
||||
v-else-if="notification.type === 'reaction'"
|
||||
|
|
@ -86,6 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
|
||||
<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
|
||||
<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
|
||||
<span v-else-if="notification.type === 'sharedAccessGranted'">{{ i18n.ts._notification.sharedAccessGranted }}</span>
|
||||
<span v-else-if="notification.type === 'sharedAccessRevoked'">{{ i18n.ts._notification.sharedAccessRevoked }}</span>
|
||||
<span v-else-if="notification.type === 'sharedAccessLogin'">{{ i18n.ts._notification.sharedAccessLogin }}</span>
|
||||
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
|
||||
</header>
|
||||
<div>
|
||||
|
|
@ -191,6 +199,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
|
||||
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||
</MkA>
|
||||
|
||||
<div v-else-if="notification.type === 'sharedAccessGranted'">
|
||||
<MkA :to="userPage(notification.user)">
|
||||
<I18n :src="i18n.ts.sharedAccessGranted" tag="span">
|
||||
<template #target><MkAcct :user="notification.user"/></template>
|
||||
<template #rank>{{ i18n.ts._ranks[notification.rank ?? 'default'] }}</template>
|
||||
<template #perms>{{ notification.permCount ?? 0 }}</template>
|
||||
</I18n>
|
||||
</MkA>
|
||||
</div>
|
||||
|
||||
<MkA v-else-if="notification.type === 'sharedAccessRevoked'" :to="userPage(notification.user)">
|
||||
<I18n :src="i18n.ts.sharedAccessRevoked" tag="span">
|
||||
<template #target><MkAcct :user="notification.user"/></template>
|
||||
</I18n>
|
||||
</MkA>
|
||||
|
||||
<MkA v-else-if="notification.type === 'sharedAccessLogin'" :to="userPage(notification.user)">
|
||||
<I18n :src="i18n.ts.sharedAccessLogin" tag="span">
|
||||
<template #target><MkAcct :user="notification.user"/></template>
|
||||
</I18n>
|
||||
</MkA>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="400"
|
||||
:height="450"
|
||||
:width="500"
|
||||
:height="600"
|
||||
:withOkButton="true"
|
||||
:okButtonDisabled="false"
|
||||
:canClose="false"
|
||||
|
|
@ -25,29 +25,98 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div>
|
||||
<MkInput v-model="name">
|
||||
<template #label>{{ i18n.ts.name }}</template>
|
||||
<template #caption>{{ i18n.ts.accessTokenNameDescription }}</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
<div><b>{{ i18n.ts.permission }}</b></div>
|
||||
<div class="_buttons">
|
||||
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
|
||||
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
||||
</div>
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
|
||||
</div>
|
||||
<div v-if="iAmAdmin" :class="$style.adminPermissions">
|
||||
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
|
||||
|
||||
<MkSelect v-if="$i?.isAdmin" v-model="rank">
|
||||
<template #label>{{ i18n.ts.overrideRank }}</template>
|
||||
<template #caption>{{ i18n.ts.overrideRankDescription }}</template>
|
||||
|
||||
<option value="admin">{{ i18n.ts._ranks.admin }}</option>
|
||||
<option value="mod">{{ i18n.ts._ranks.mod }}</option>
|
||||
<option value="user">{{ i18n.ts._ranks.user }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect v-else v-model="rank">
|
||||
<template #label>{{ i18n.ts.overrideRank }}</template>
|
||||
<template #caption>{{ i18n.ts.overrideRankDescription }}</template>
|
||||
|
||||
<option value="mod">{{ i18n.ts._ranks.mod }}</option>
|
||||
<option value="user">{{ i18n.ts._ranks.user }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder v-if="withSharedAccess !== false" :defaultOpen="withSharedAccess === true">
|
||||
<template #label>{{ i18n.ts.sharedAccess }}</template>
|
||||
<template #suffix>{{ grantees.length || i18n.ts.none }}</template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
|
||||
<div>{{ i18n.ts.sharedAccessDescription }}</div>
|
||||
|
||||
<MkButton primary @click="addGrantee">
|
||||
<i class="ti ti-plus"></i> {{ i18n.ts.addGrantee }}
|
||||
</MkButton>
|
||||
|
||||
<div v-for="(grantee, i) of grantees" :key="grantee.id" :class="$style.grantee">
|
||||
<MkUserCardMini :user="grantee" :withChart="false"/>
|
||||
<button v-tooltip="i18n.ts.removeGrantee" class="_textButton" @click="() => removeGrantee(i)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.permission }}</template>
|
||||
<template #suffix>{{ permsCount || i18n.ts.none }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div>{{ i18n.ts.permissionsDescription }}</div>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="enableAllRead">{{ i18n.ts.enableAllRead }}</MkSwitch>
|
||||
<MkSwitch v-model="enableAllWrite">{{ i18n.ts.enableAllWrite }}</MkSwitch>
|
||||
</div>
|
||||
|
||||
<div :class="$style.divider"></div>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="iAmAdmin || iAmModerator">
|
||||
<template #label>{{ i18n.ts.adminPermission }}</template>
|
||||
<template #suffix>{{ adminPermsCount || i18n.ts.none }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div>{{ i18n.ts.adminPermissionsDescription }}</div>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="enableAllReadAdmin" :disabled="rank === 'user'">{{ i18n.ts.enableAllRead }}</MkSwitch>
|
||||
<MkSwitch v-model="enableAllWriteAdmin" :disabled="rank === 'user'">{{ i18n.ts.enableAllWrite }}</MkSwitch>
|
||||
</div>
|
||||
|
||||
<div :class="$style.divider"></div>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch
|
||||
v-for="kind in Object.keys(permissionSwitchesForAdmin)"
|
||||
:key="kind"
|
||||
v-model="permissionSwitchesForAdmin[kind]"
|
||||
:disabled="rank === 'user'"
|
||||
>
|
||||
{{ i18n.ts._permissions[kind] }}
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, ref } from 'vue';
|
||||
import { useTemplateRef, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkInput from './MkInput.vue';
|
||||
import MkSwitch from './MkSwitch.vue';
|
||||
|
|
@ -55,32 +124,52 @@ 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, iAmModerator } 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.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string | null;
|
||||
information?: string | null;
|
||||
initialName?: string | null;
|
||||
initialPermissions?: (typeof Misskey.permissions)[number][] | null;
|
||||
withSharedAccess?: boolean | null;
|
||||
}>(), {
|
||||
title: null,
|
||||
information: null,
|
||||
initialName: null,
|
||||
initialPermissions: null,
|
||||
withSharedAccess: null,
|
||||
});
|
||||
|
||||
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[], rank: string }): void;
|
||||
}>();
|
||||
|
||||
const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin'));
|
||||
const defaultReadPermissions = defaultPermissions.filter(p => p.startsWith('read:'));
|
||||
const defaultWritePermissions = defaultPermissions.filter(p => p.startsWith('write:'));
|
||||
|
||||
const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
|
||||
const adminReadPermissions = adminPermissions.filter(p => p.startsWith('read:'));
|
||||
const adminWritePermissions = adminPermissions.filter(p => p.startsWith('write:'));
|
||||
|
||||
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<Misskey.entities.User[]>([]);
|
||||
const rank = ref<'admin' | 'mod' | 'user'>(
|
||||
$i?.isAdmin
|
||||
? 'admin'
|
||||
: $i?.isModerator
|
||||
? 'mod'
|
||||
: 'user');
|
||||
const permsCount = computed(() => Object.values(permissionSwitches.value).reduce((sum, active) => active ? sum + 1 : sum, 0));
|
||||
const adminPermsCount = computed(() => Object.values(permissionSwitchesForAdmin.value).reduce((sum, active) => active ? sum + 1 : sum, 0));
|
||||
|
||||
if (props.initialPermissions) {
|
||||
for (const kind of props.initialPermissions) {
|
||||
|
|
@ -98,37 +187,84 @@ if (props.initialPermissions) {
|
|||
}
|
||||
}
|
||||
|
||||
function ok(): void {
|
||||
const enableAllRead = computed({
|
||||
get() {
|
||||
return defaultReadPermissions.every(p => permissionSwitches.value[p]);
|
||||
},
|
||||
set(value: boolean) {
|
||||
defaultReadPermissions.forEach(p => permissionSwitches.value[p] = value);
|
||||
},
|
||||
});
|
||||
|
||||
const enableAllWrite = computed({
|
||||
get() {
|
||||
return defaultWritePermissions.every(p => permissionSwitches.value[p]);
|
||||
},
|
||||
set(value: boolean) {
|
||||
defaultWritePermissions.forEach(p => permissionSwitches.value[p] = value);
|
||||
},
|
||||
});
|
||||
|
||||
const enableAllReadAdmin = computed({
|
||||
get() {
|
||||
return adminReadPermissions.every(p => permissionSwitchesForAdmin.value[p]);
|
||||
},
|
||||
set(value: boolean) {
|
||||
adminReadPermissions.forEach(p => permissionSwitchesForAdmin.value[p] = value);
|
||||
},
|
||||
});
|
||||
|
||||
const enableAllWriteAdmin = computed({
|
||||
get() {
|
||||
return adminWritePermissions.every(p => permissionSwitchesForAdmin.value[p]);
|
||||
},
|
||||
set(value: boolean) {
|
||||
adminWritePermissions.forEach(p => permissionSwitchesForAdmin.value[p] = value);
|
||||
},
|
||||
});
|
||||
|
||||
async function ok(): Promise<void> {
|
||||
if (props.withSharedAccess === true && grantees.value.length < 1) {
|
||||
await os.alert({
|
||||
type: 'warning',
|
||||
title: i18n.ts.grantSharedAccessNoSelection,
|
||||
text: i18n.ts.grantSharedAccessNoSelection2,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Object.values(permissionSwitches.value).some(v => v) && !Object.values(permissionSwitchesForAdmin.value).some(v => v)) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
text: i18n.ts.tokenHasNoPermissionsConfirm,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
emit('done', {
|
||||
name: name.value,
|
||||
permissions: [
|
||||
...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]),
|
||||
...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []),
|
||||
...((iAmAdmin && rank.value === 'admin') ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []),
|
||||
],
|
||||
grantees: grantees.value.map(g => g.id),
|
||||
rank: rank.value,
|
||||
});
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function disableAll(): void {
|
||||
for (const p in permissionSwitches.value) {
|
||||
permissionSwitches.value[p] = false;
|
||||
}
|
||||
if (iAmAdmin) {
|
||||
for (const p in permissionSwitchesForAdmin.value) {
|
||||
permissionSwitchesForAdmin.value[p] = false;
|
||||
}
|
||||
}
|
||||
async function addGrantee(): Promise<void> {
|
||||
const user = await os.selectUser({
|
||||
localOnly: true,
|
||||
});
|
||||
grantees.value.push(user);
|
||||
}
|
||||
|
||||
function enableAll(): void {
|
||||
for (const p in permissionSwitches.value) {
|
||||
permissionSwitches.value[p] = true;
|
||||
}
|
||||
if (iAmAdmin) {
|
||||
for (const p in permissionSwitchesForAdmin.value) {
|
||||
permissionSwitchesForAdmin.value[p] = true;
|
||||
}
|
||||
}
|
||||
function removeGrantee(index: number) {
|
||||
grantees.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -147,4 +283,19 @@ function enableAll(): void {
|
|||
color: var(--MI_THEME-error);
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.grantee {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: var(--MI-marginHalf);
|
||||
}
|
||||
|
||||
.grantee > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-bottom: 1px solid var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div v-adaptive-bg :class="[$style.root]">
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<div :class="$style.body">
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator :preview="withLink" :link="withLink"/>
|
||||
<component :is="withLink ? MkA : 'div'" :class="$style.body" :to="userPage(user)">
|
||||
<span :class="$style.name"><MkUserName :user="user"/></span>
|
||||
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
|
||||
</div>
|
||||
</component>
|
||||
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -18,14 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import MkA from '@/components/global/MkA.vue';
|
||||
import { misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import { acct, userPage } from '@/filters/user.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.User;
|
||||
withChart?: boolean;
|
||||
withLink?: boolean;
|
||||
}>(), {
|
||||
withChart: true,
|
||||
withLink: false,
|
||||
});
|
||||
|
||||
const chartValues = ref<number[] | null>(null);
|
||||
|
|
|
|||
120
packages/frontend/src/components/SkSigninSharedAccessDialog.vue
Normal file
120
packages/frontend/src/components/SkSigninSharedAccessDialog.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="modal"
|
||||
:width="500"
|
||||
:height="600"
|
||||
:withOkButton="false"
|
||||
:canClose="true"
|
||||
@close="onClose"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.loginWithSharedAccess }}</template>
|
||||
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps">
|
||||
<div v-for="(grant, i) of items" :key="grant.id" :class="$style.grant">
|
||||
<MkUserCardMini :user="grant.user" :withChart="false" :class="$style.user"/>
|
||||
<div class="_gaps_s">
|
||||
<button v-tooltip="i18n.ts.login" class="_textButton" @click="onLogin(grant.id)"><i class="ph-sign-in ph-bold ph-lg"></i></button>
|
||||
<button v-if="isExpanded(i)" v-tooltip="i18n.ts.collapse" class="_textButton" @click="collapse(i)"><i class="ph-caret-up ph-bold ph-lg"></i></button>
|
||||
<button v-else v-tooltip="i18n.ts.expand" class="_textButton" @click="expand(i)"><i class="ph-caret-down ph-bold ph-lg"></i></button>
|
||||
</div>
|
||||
<div v-if="isExpanded(i)" :class="$style.perms">
|
||||
<span>{{ i18n.ts.permissions }}:</span>
|
||||
<ul>
|
||||
<li v-for="perm of grant.permissions" :key="perm">{{ i18n.ts._permissions[perm] ?? perm }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { id: string, i: string }): void;
|
||||
(ev: 'closed'): void;
|
||||
(ev: 'cancelled'): void;
|
||||
}>();
|
||||
|
||||
const pagination = computed(() => ({
|
||||
endpoint: 'i/shared-access/list',
|
||||
params: {},
|
||||
limit: 10,
|
||||
} satisfies Paging));
|
||||
|
||||
const modal = useTemplateRef('modal');
|
||||
const expandedIds = ref(new Set<number>());
|
||||
|
||||
function isExpanded(i: number) {
|
||||
return expandedIds.value.has(i);
|
||||
}
|
||||
|
||||
function expand(i: number) {
|
||||
expandedIds.value.add(i);
|
||||
}
|
||||
|
||||
function collapse(i: number) {
|
||||
expandedIds.value.delete(i);
|
||||
}
|
||||
|
||||
async function onLogin(grantId: string) {
|
||||
const { userId, token } = await os.apiWithDialog('i/shared-access/login', { grantId });
|
||||
if (modal.value) modal.value.close();
|
||||
emit('done', { id: userId, i: token });
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (modal.value) modal.value.close();
|
||||
emit('cancelled');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.grant {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--MI-marginHalf);
|
||||
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.perms {
|
||||
width: 100%;
|
||||
padding: var(--MI-marginHalf);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.perms > ul {
|
||||
margin: 0 0 0 1.5em;
|
||||
padding: 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -32,12 +32,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||
<template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="token.rank" oneline>
|
||||
<template #key>{{ i18n.ts.rank }}</template>
|
||||
<template #value>{{ i18n.ts._ranks[token.rank ?? 'default'] ?? token.rank }}</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<MkFolder>
|
||||
<MkFolder v-if="token.grantees.length > 0" :defaultOpen="onlySharedAccess">
|
||||
<template #label>{{ i18n.ts.sharedAccess }}</template>
|
||||
<template #suffix>{{ token.grantees.length }}</template>
|
||||
|
||||
<MkUserCardMini v-for="grantee of token.grantees" :key="grantee.id" :user="grantee" :withChart="true" :withLink="true"/>
|
||||
</MkFolder>
|
||||
<MkFolder v-if="standardPerms(token.permission).length > 0">
|
||||
<template #label>{{ i18n.ts.permission }}</template>
|
||||
<template #suffix>{{ Object.keys(token.permission).length === 0 ? i18n.ts.none : Object.keys(token.permission).length }}</template>
|
||||
<template #suffix>{{ standardPerms(token.permission).length }}</template>
|
||||
<ul>
|
||||
<li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
|
||||
<li v-for="p of standardPerms(token.permission)" :key="p">{{ i18n.ts._permissions[p] }}</li>
|
||||
</ul>
|
||||
</MkFolder>
|
||||
<MkFolder v-if="adminPerms(token.permission).length > 0">
|
||||
<template #label>{{ i18n.ts.adminPermission }}</template>
|
||||
<template #suffix>{{ adminPerms(token.permission).length }}</template>
|
||||
<ul>
|
||||
<li v-for="p of adminPerms(token.permission)" :key="p">{{ i18n.ts._permissions[p] }}</li>
|
||||
</ul>
|
||||
</MkFolder>
|
||||
</div>
|
||||
|
|
@ -51,6 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import FormPagination from '@/components/MkPagination.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -58,24 +76,61 @@ import { definePage } from '@/page.js';
|
|||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
|
||||
const list = ref<InstanceType<typeof FormPagination>>();
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'i/apps' as const,
|
||||
const props = withDefaults(defineProps<{
|
||||
onlySharedAccess?: boolean,
|
||||
limit?: number,
|
||||
}>(), {
|
||||
onlySharedAccess: false,
|
||||
limit: 100,
|
||||
noPaging: true,
|
||||
});
|
||||
|
||||
const pagination = computed(() => ({
|
||||
endpoint: 'i/apps' as const,
|
||||
limit: props.limit,
|
||||
params: {
|
||||
sort: '+lastUsedAt',
|
||||
onlySharedAccess: props.onlySharedAccess,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
function revoke(token) {
|
||||
misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => {
|
||||
list.value?.reload();
|
||||
async function revoke(token: Misskey.entities.IAppsResponse[number]) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: token.grantees.length > 0
|
||||
? i18n.tsx.confirmRevokeSharedToken({ num: token.grantees.length })
|
||||
: i18n.ts.confirmRevokeToken,
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await os.promiseDialog(async () => {
|
||||
await misskeyApi('i/revoke-token', { tokenId: token.id });
|
||||
|
||||
await list.value?.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function isAdmin(perm: string): boolean {
|
||||
return perm.startsWith('read:admin') || perm.startsWith('write:admin');
|
||||
}
|
||||
|
||||
function standardPerms(perms: string[]): string[] {
|
||||
return perms.filter(perm => !isAdmin(perm));
|
||||
}
|
||||
|
||||
function adminPerms(perms: string[]): string[] {
|
||||
return perms.filter(perm => isAdmin(perm));
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload: () => list.value?.reload(),
|
||||
});
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
|||
|
|
@ -84,11 +84,13 @@ const pagination = {
|
|||
function generateToken() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
|
||||
done: async result => {
|
||||
const { name, permissions } = 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({
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<X2fa/>
|
||||
|
||||
<SearchMarker :keywords="['shared', 'access']">
|
||||
<FormSection>
|
||||
<template #label><SearchLabel>{{ i18n.ts.sharedAccess }}</SearchLabel></template>
|
||||
<template #description>{{ i18n.ts.sharedAccessDescription2 }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkButton primary @click="grantSharedAccess">{{ i18n.ts.grantSharedAccessButton }}</MkButton>
|
||||
|
||||
<XApps ref="apps" :onlySharedAccess="true" :limit="10"/>
|
||||
</div>
|
||||
</FormSection>
|
||||
</SearchMarker>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||
<MkPagination :pagination="pagination" disableAutoLoad>
|
||||
|
|
@ -53,8 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import X2fa from './2fa.vue';
|
||||
import XApps from '@/pages/settings/apps.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
|
@ -113,6 +127,36 @@ async function regenerateToken() {
|
|||
});
|
||||
}
|
||||
|
||||
const apps = useTemplateRef('apps');
|
||||
|
||||
function grantSharedAccess() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
|
||||
withSharedAccess: true,
|
||||
}, {
|
||||
done: async result => {
|
||||
const { name, permissions, grantees, rank } = result;
|
||||
await os.promiseDialog(async () => {
|
||||
await misskeyApi('miauth/gen-token', {
|
||||
session: null,
|
||||
name: name,
|
||||
permission: permissions,
|
||||
grantees: grantees,
|
||||
rank: rank,
|
||||
});
|
||||
|
||||
await apps.value?.reload();
|
||||
});
|
||||
|
||||
await os.alert({
|
||||
type: 'success',
|
||||
title: i18n.ts.grantSharedAccessSuccess,
|
||||
text: i18n.tsx.grantSharedAccessSuccess2({ num: grantees.length }),
|
||||
});
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
|||
|
|
@ -107,13 +107,15 @@ export async function authorizePlugin(plugin: Plugin) {
|
|||
information: i18n.ts.pluginTokenRequestedDescription,
|
||||
initialName: plugin.name,
|
||||
initialPermissions: plugin.permissions,
|
||||
withSharedAccess: false,
|
||||
}, {
|
||||
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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1987,6 +1987,10 @@ declare namespace entities {
|
|||
IRegistryScopesWithDomainResponse,
|
||||
IRegistrySetRequest,
|
||||
IRevokeTokenRequest,
|
||||
ISharedAccessListRequest,
|
||||
ISharedAccessListResponse,
|
||||
ISharedAccessLoginRequest,
|
||||
ISharedAccessLoginResponse,
|
||||
ISigninHistoryRequest,
|
||||
ISigninHistoryResponse,
|
||||
IUnpinRequest,
|
||||
|
|
@ -2773,6 +2777,18 @@ type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['conten
|
|||
// @public (undocumented)
|
||||
function isAPIError(reason: Record<PropertyKey, unknown>): reason is APIError;
|
||||
|
||||
// @public (undocumented)
|
||||
type ISharedAccessListRequest = operations['i___shared-access___list']['requestBody']['content']['application/json'];
|
||||
|
||||
// @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'];
|
||||
|
||||
|
|
@ -3435,7 +3451,7 @@ type PartialRolePolicyOverride = Partial<{
|
|||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:cw-note", "write:admin:cw-instance", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
|
||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "read:admin:abuse-report:notification-recipient", "write:admin:abuse-report:notification-recipient", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:cw-note", "write:admin:cw-instance", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
|
||||
|
||||
// @public (undocumented)
|
||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||
|
|
|
|||
|
|
@ -3470,6 +3470,30 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* 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<E extends 'i/shared-access/list', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* 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<E extends 'i/shared-access/login', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -462,6 +462,10 @@ import type {
|
|||
IRegistryScopesWithDomainResponse,
|
||||
IRegistrySetRequest,
|
||||
IRevokeTokenRequest,
|
||||
ISharedAccessListRequest,
|
||||
ISharedAccessListResponse,
|
||||
ISharedAccessLoginRequest,
|
||||
ISharedAccessLoginResponse,
|
||||
ISigninHistoryRequest,
|
||||
ISigninHistoryResponse,
|
||||
IUnpinRequest,
|
||||
|
|
@ -983,6 +987,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: ISharedAccessListRequest; 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 };
|
||||
|
|
|
|||
|
|
@ -465,6 +465,10 @@ 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 ISharedAccessListRequest = operations['i___shared-access___list']['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'];
|
||||
|
|
|
|||
|
|
@ -2997,6 +2997,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
|
||||
|
|
@ -4585,6 +4605,7 @@ export type components = {
|
|||
achievements: components['schemas']['Achievement'][];
|
||||
loggedInDays: number;
|
||||
policies: components['schemas']['RolePolicies'];
|
||||
permissions: string[];
|
||||
/** @default false */
|
||||
twoFactorEnabled: boolean;
|
||||
/** @default false */
|
||||
|
|
@ -5010,6 +5031,39 @@ export type components = {
|
|||
/** Format: id */
|
||||
userId: string;
|
||||
note: components['schemas']['Note'];
|
||||
} | ({
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** @enum {string} */
|
||||
type: 'sharedAccessGranted';
|
||||
user: components['schemas']['UserLite'];
|
||||
/** Format: id */
|
||||
userId: string;
|
||||
permCount?: number;
|
||||
/** @enum {string|null} */
|
||||
rank?: 'admin' | 'mod' | 'user';
|
||||
}) | {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** @enum {string} */
|
||||
type: 'sharedAccessRevoked';
|
||||
user: components['schemas']['UserLite'];
|
||||
/** Format: id */
|
||||
userId: string;
|
||||
} | {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** @enum {string} */
|
||||
type: 'sharedAccessLogin';
|
||||
user: components['schemas']['UserLite'];
|
||||
/** Format: id */
|
||||
userId: string;
|
||||
} | {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
|
|
@ -22709,6 +22763,13 @@ export type operations = {
|
|||
'application/json': {
|
||||
/** @enum {string} */
|
||||
sort?: '+createdAt' | '-createdAt' | '+lastUsedAt' | '-lastUsedAt';
|
||||
onlySharedAccess?: boolean;
|
||||
/** @default 30 */
|
||||
limit?: number;
|
||||
/** Format: misskey:id */
|
||||
sinceId?: string;
|
||||
/** Format: misskey:id */
|
||||
untilId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -22716,7 +22777,7 @@ export type operations = {
|
|||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
'application/json': ({
|
||||
/** Format: misskey:id */
|
||||
id: string;
|
||||
name?: string;
|
||||
|
|
@ -22725,7 +22786,10 @@ export type operations = {
|
|||
/** Format: date-time */
|
||||
lastUsedAt?: string;
|
||||
permission: string[];
|
||||
}[];
|
||||
grantees: components['schemas']['UserLite'][];
|
||||
/** @enum {string|null} */
|
||||
rank: 'admin' | 'mod' | 'user';
|
||||
})[];
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
|
|
@ -24117,8 +24181,8 @@ export type operations = {
|
|||
untilId?: string;
|
||||
/** @default true */
|
||||
markAsRead?: boolean;
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -24185,8 +24249,8 @@ export type operations = {
|
|||
untilId?: string;
|
||||
/** @default true */
|
||||
markAsRead?: boolean;
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -25151,6 +25215,140 @@ 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': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** @default 30 */
|
||||
limit?: number;
|
||||
/** Format: misskey:id */
|
||||
sinceId?: string;
|
||||
/** Format: misskey:id */
|
||||
untilId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ({
|
||||
id: string;
|
||||
user: components['schemas']['UserLite'];
|
||||
permissions: string[];
|
||||
/** @enum {string|null} */
|
||||
rank: 'admin' | 'mod' | 'user';
|
||||
})[];
|
||||
};
|
||||
};
|
||||
/** @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.
|
||||
|
|
@ -26304,6 +26502,9 @@ export type operations = {
|
|||
description?: string | null;
|
||||
iconUrl?: string | null;
|
||||
permission: string[];
|
||||
grantees?: string[];
|
||||
/** @enum {string|null} */
|
||||
rank?: 'admin' | 'mod' | 'user';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export const permissions = [
|
|||
'read:admin:meta',
|
||||
'write:admin:reset-password',
|
||||
'write:admin:resolve-abuse-user-report',
|
||||
'read:admin:abuse-report:notification-recipient',
|
||||
'write:admin:abuse-report:notification-recipient',
|
||||
'write:admin:send-email',
|
||||
'read:admin:server-info',
|
||||
'read:admin:show-moderation-log',
|
||||
|
|
|
|||
|
|
@ -329,6 +329,9 @@ _notification:
|
|||
scheduledNoteFailed: "Posting scheduled note failed"
|
||||
scheduledNotePosted: "Scheduled Note was posted"
|
||||
importOfXCompleted: "Import of {x} has been completed"
|
||||
sharedAccessGranted: "Shared access granted"
|
||||
sharedAccessRevoked: "Shared access revoked"
|
||||
sharedAccessLogin: "Shared access login"
|
||||
_types:
|
||||
renote: "Boosts"
|
||||
edited: "Edits"
|
||||
|
|
@ -519,6 +522,8 @@ _permissions:
|
|||
"write:admin:reject-quotes": "Allow/Prohibit quote posts from a user"
|
||||
"read:notes-schedule": "View your list of scheduled notes"
|
||||
"write:notes-schedule": "Compose or delete scheduled notes"
|
||||
"read:admin:abuse-report:notification-recipient": "Read abuse report notification recipients"
|
||||
"write:admin:abuse-report:notification-recipient": "Edit abuse report notification recipients"
|
||||
|
||||
robotsTxt: "Custom robots.txt"
|
||||
robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey."
|
||||
|
|
@ -678,3 +683,41 @@ clearCachedFilesOptions:
|
|||
customFontSize: "Custom font size"
|
||||
|
||||
hideAds: "Hide ads"
|
||||
|
||||
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."
|
||||
sharedAccessDescription2: "Shared access allows another user to access your account without using your password. You may select exactly which features and data are available to guest users."
|
||||
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"
|
||||
overrideRank: "Limit rank"
|
||||
overrideRankDescription: "Limits the user rank (admin, moderator, or user) for apps using this token."
|
||||
rank: "Rank"
|
||||
_ranks:
|
||||
admin: "Admin"
|
||||
mod: "Moderator"
|
||||
user: "User"
|
||||
default: "default"
|
||||
permissionsLabel: "Permissions: {num}"
|
||||
sharedAccessGranted: "You have been granted shared access to {target} with {rank} rank and {perms} permissions."
|
||||
sharedAccessRevoked: "Shared access to {target} has been revoked."
|
||||
sharedAccessLogin: "{target} logged in via shared access."
|
||||
accessTokenNameDescription: "Unique name to record the purpose of this access token"
|
||||
confirmRevokeToken: "Are you sure you want to revoke this token?"
|
||||
confirmRevokeSharedToken: "Are you sure you want to revoke this token? {num} shared other users will lose shared access."
|
||||
grantSharedAccessButton: "Grant shared access"
|
||||
grantSharedAccessNoSelection: "No shared access listed"
|
||||
grantSharedAccessNoSelection2: "No shared access users were selected. Please add at least one user in the \"shared access\" section."
|
||||
grantSharedAccessSuccess: "Shared access granted"
|
||||
grantSharedAccessSuccess2: "Shared access has been granted to {num} users."
|
||||
tokenHasNoPermissionsConfirm: "Are you sure you want to create a token with no permissions?"
|
||||
enableAllRead: "Enable all read-only permissions"
|
||||
enableAllWrite: "Enable all write/edit permissions"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue