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
|
* Compose or delete scheduled notes
|
||||||
*/
|
*/
|
||||||
"write:notes-schedule": string;
|
"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": {
|
"_auth": {
|
||||||
/**
|
/**
|
||||||
|
|
@ -10434,6 +10442,18 @@ export interface Locale extends ILocale {
|
||||||
* Import of {x} has been completed
|
* Import of {x} has been completed
|
||||||
*/
|
*/
|
||||||
"importOfXCompleted": ParameterizedString<"x">;
|
"importOfXCompleted": ParameterizedString<"x">;
|
||||||
|
/**
|
||||||
|
* Shared access granted
|
||||||
|
*/
|
||||||
|
"sharedAccessGranted": string;
|
||||||
|
/**
|
||||||
|
* Shared access revoked
|
||||||
|
*/
|
||||||
|
"sharedAccessRevoked": string;
|
||||||
|
/**
|
||||||
|
* Shared access login
|
||||||
|
*/
|
||||||
|
"sharedAccessLogin": string;
|
||||||
};
|
};
|
||||||
"_deck": {
|
"_deck": {
|
||||||
/**
|
/**
|
||||||
|
|
@ -13467,6 +13487,152 @@ export interface Locale extends ILocale {
|
||||||
* Hide ads
|
* Hide ads
|
||||||
*/
|
*/
|
||||||
"hideAds": string;
|
"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: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[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
|
@bindThis
|
||||||
public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) {
|
public async hasPermissionToViewRoomTimeline(me: MiUser, room: MiChatRoom) {
|
||||||
if (await this.isRoomMember(room, meId)) {
|
if (await this.isRoomMember(room, me.id)) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
const iAmModerator = await this.roleService.isModerator(me);
|
||||||
if (iAmModerator) {
|
if (iAmModerator) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -563,12 +563,12 @@ export class ChatService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) {
|
public async hasPermissionToDeleteRoom(me: MiUser, room: MiChatRoom) {
|
||||||
if (room.ownerId === meId) {
|
if (room.ownerId === me.id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
const iAmModerator = await this.roleService.isModerator(me);
|
||||||
if (iAmModerator) {
|
if (iAmModerator) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { getCallerId } from '@/misc/attach-caller-id.js';
|
||||||
|
|
||||||
export type RolePolicies = {
|
export type RolePolicies = {
|
||||||
gtlAvailable: boolean;
|
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 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 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));
|
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
|
@bindThis
|
||||||
public async isModerator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
public async isModerator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||||
if (user == null) return false;
|
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);
|
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async isAdministrator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
public async isAdministrator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||||
if (user == null) return false;
|
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);
|
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(),
|
createdAt: this.idService.parse(clip.id).date.toISOString(),
|
||||||
lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null,
|
lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null,
|
||||||
userId: clip.userId,
|
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,
|
name: clip.name,
|
||||||
description: clip.description,
|
description: clip.description,
|
||||||
isPublic: clip.isPublic,
|
isPublic: clip.isPublic,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { In, EntityNotFoundError } from 'typeorm';
|
import { In, EntityNotFoundError } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
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 { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
|
import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
|
||||||
import type { MiNote } from '@/models/Note.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 { UserEntityService } from './UserEntityService.js';
|
||||||
import type { NoteEntityService } from './NoteEntityService.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> {
|
function undefOnMissing<T>(packPromise: Promise<T>): Promise<T | undefined> {
|
||||||
return packPromise.catch(err => {
|
return packPromise.catch(err => {
|
||||||
|
|
@ -49,6 +49,9 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
@Inject(DI.followRequestsRepository)
|
@Inject(DI.followRequestsRepository)
|
||||||
private followRequestsRepository: FollowRequestsRepository,
|
private followRequestsRepository: FollowRequestsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.accessTokensRepository)
|
||||||
|
private readonly accessTokensRepository: AccessTokensRepository,
|
||||||
|
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +202,10 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
header: notification.customHeader,
|
header: notification.customHeader,
|
||||||
icon: notification.customIcon,
|
icon: notification.customIcon,
|
||||||
} : {}),
|
} : {}),
|
||||||
|
...(notification.type === 'sharedAccessGranted' ? {
|
||||||
|
permCount: notification.permCount,
|
||||||
|
rank: notification.rank,
|
||||||
|
} : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
import _Ajv from 'ajv';
|
import _Ajv from 'ajv';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
|
|
@ -54,6 +55,7 @@ import { ChatService } from '@/core/ChatService.js';
|
||||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
import type { CacheService } from '@/core/CacheService.js';
|
import type { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { getCallerId } from '@/misc/attach-caller-id.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
import type { NoteEntityService } from './NoteEntityService.js';
|
import type { NoteEntityService } from './NoteEntityService.js';
|
||||||
import type { PageEntityService } from './PageEntityService.js';
|
import type { PageEntityService } from './PageEntityService.js';
|
||||||
|
|
@ -432,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
userMemos?: Map<MiUser['id'], string | null>,
|
userMemos?: Map<MiUser['id'], string | null>,
|
||||||
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
|
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
|
||||||
iAmModerator?: boolean,
|
iAmModerator?: boolean,
|
||||||
|
iAmAdmin?: boolean,
|
||||||
userIdsByUri?: Map<string, string>,
|
userIdsByUri?: Map<string, string>,
|
||||||
instances?: Map<string, MiInstance | null>,
|
instances?: Map<string, MiInstance | null>,
|
||||||
securityKeyCounts?: Map<string, number>,
|
securityKeyCounts?: Map<string, number>,
|
||||||
|
|
@ -477,7 +480,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
const isDetailed = opts.schema !== 'UserLite';
|
const isDetailed = opts.schema !== 'UserLite';
|
||||||
const meId = me ? me.id : null;
|
const meId = me ? me.id : null;
|
||||||
const isMe = meId === user.id;
|
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
|
const profile = isDetailed
|
||||||
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
? (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 :
|
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
|
||||||
null;
|
null;
|
||||||
|
|
||||||
const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null;
|
|
||||||
const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null;
|
|
||||||
const unreadAnnouncements = isMe && isDetailed ?
|
const unreadAnnouncements = isMe && isDetailed ?
|
||||||
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
|
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
|
||||||
createdAt: this.idService.parse(announcement.id).date.toISOString(),
|
createdAt: this.idService.parse(announcement.id).date.toISOString(),
|
||||||
|
|
@ -663,8 +665,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
bannerId: user.bannerId,
|
bannerId: user.bannerId,
|
||||||
backgroundId: user.backgroundId,
|
backgroundId: user.backgroundId,
|
||||||
followedMessage: profile!.followedMessage,
|
followedMessage: profile!.followedMessage,
|
||||||
isModerator: isModerator,
|
isModerator: iAmModerator,
|
||||||
isAdmin: isAdmin,
|
isAdmin: iAmAdmin,
|
||||||
isSystem: isSystemAccount(user),
|
isSystem: isSystemAccount(user),
|
||||||
injectFeaturedNote: profile!.injectFeaturedNote,
|
injectFeaturedNote: profile!.injectFeaturedNote,
|
||||||
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
|
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
|
||||||
|
|
@ -699,6 +701,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
achievements: profile!.achievements,
|
achievements: profile!.achievements,
|
||||||
loggedInDays: profile!.loggedInDates.length,
|
loggedInDays: profile!.loggedInDates.length,
|
||||||
policies: fetchPolicies(),
|
policies: fetchPolicies(),
|
||||||
|
permissions: this.getPermissions(user, iAmModerator, iAmAdmin),
|
||||||
defaultCW: profile!.defaultCW,
|
defaultCW: profile!.defaultCW,
|
||||||
defaultCWPriority: profile!.defaultCWPriority,
|
defaultCWPriority: profile!.defaultCWPriority,
|
||||||
allowUnsignedFetch: user.allowUnsignedFetch,
|
allowUnsignedFetch: user.allowUnsignedFetch,
|
||||||
|
|
@ -767,7 +770,10 @@ export class UserEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
const _userIds = _users.map(u => u.id);
|
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 meId = me ? me.id : null;
|
||||||
const isDetailed = options && options.schema !== 'UserLite';
|
const isDetailed = options && options.schema !== 'UserLite';
|
||||||
const isDetailedAndMod = isDetailed && iAmModerator;
|
const isDetailedAndMod = isDetailed && iAmModerator;
|
||||||
|
|
@ -864,6 +870,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
userMemos: userMemos,
|
userMemos: userMemos,
|
||||||
pinNotes: pinNotes,
|
pinNotes: pinNotes,
|
||||||
iAmModerator,
|
iAmModerator,
|
||||||
|
iAmAdmin,
|
||||||
userIdsByUri,
|
userIdsByUri,
|
||||||
instances,
|
instances,
|
||||||
securityKeyCounts,
|
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 { MiUser } from './User.js';
|
||||||
import { MiApp } from './App.js';
|
import { MiApp } from './App.js';
|
||||||
|
|
||||||
|
export const accessTokenRanks = ['user', 'mod', 'admin'] as const;
|
||||||
|
export type AccessTokenRank = typeof accessTokenRanks[number];
|
||||||
|
|
||||||
@Entity('access_token')
|
@Entity('access_token')
|
||||||
export class MiAccessToken {
|
export class MiAccessToken {
|
||||||
@PrimaryColumn(id())
|
@PrimaryColumn(id())
|
||||||
|
|
@ -87,4 +90,25 @@ export class MiAccessToken {
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public fetched: boolean;
|
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;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
noteId: MiNote['id'];
|
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 | {
|
export type MiGroupedNotification = MiNotification | {
|
||||||
|
|
|
||||||
|
|
@ -327,4 +327,4 @@ export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<
|
||||||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||||
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
|
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,
|
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',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|
|
||||||
|
|
@ -739,6 +739,13 @@ export const packedMeDetailedOnlySchema = {
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
ref: 'RolePolicies',
|
ref: 'RolePolicies',
|
||||||
},
|
},
|
||||||
|
permissions: {
|
||||||
|
type: 'array',
|
||||||
|
nullable: false, optional: false,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
twoFactorEnabled: {
|
twoFactorEnabled: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ import type { Config } from '@/config.js';
|
||||||
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { renderFullError } from '@/misc/render-full-error.js';
|
||||||
import { ApiError } from './error.js';
|
import { ApiError } from './error.js';
|
||||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
||||||
import { renderFullError } from '@/misc/render-full-error.js';
|
|
||||||
|
|
||||||
const accessDenied = {
|
const accessDenied = {
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { MiApp } from '@/models/App.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { isNativeUserToken } from '@/misc/token.js';
|
import { isNativeUserToken } from '@/misc/token.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { attachCallerId } from '@/misc/attach-caller-id.js';
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
|
@ -62,6 +63,9 @@ export class AuthenticateService implements OnApplicationShutdown {
|
||||||
}, {
|
}, {
|
||||||
token: token, // miauth
|
token: token, // miauth
|
||||||
}],
|
}],
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accessToken == null) {
|
if (accessToken == null) {
|
||||||
|
|
@ -72,10 +76,11 @@ export class AuthenticateService implements OnApplicationShutdown {
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId,
|
// Loaded by relation above
|
||||||
() => this.usersRepository.findOneBy({
|
const user = accessToken.user as MiLocalUser;
|
||||||
id: accessToken.userId,
|
|
||||||
}) as Promise<MiLocalUser>);
|
// Attach token to user - this will be read by RoleService to drop admin/moderator permissions.
|
||||||
|
attachCallerId(user, { accessToken });
|
||||||
|
|
||||||
if (accessToken.appId) {
|
if (accessToken.appId) {
|
||||||
const app = await this.appCache.fetch(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/registry/set' from './endpoints/i/registry/set.js';
|
||||||
export * as 'i/revoke-token' from './endpoints/i/revoke-token.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/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/unpin' from './endpoints/i/unpin.js';
|
||||||
export * as 'i/update' from './endpoints/i/update.js';
|
export * as 'i/update' from './endpoints/i/update.js';
|
||||||
export * as 'i/update-email' from './endpoints/i/update-email.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);
|
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',
|
schema: 'UserDetailedNotMe',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
secret: secret,
|
secret: secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.appEntityService.pack(app, null, {
|
return await this.appEntityService.pack(app, me, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecret: true,
|
includeSecret: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: accessToken.token,
|
accessToken: accessToken.token,
|
||||||
user: await this.userEntityService.pack(session.userId, null, {
|
user: await this.userEntityService.pack(session.userId, me, {
|
||||||
schema: 'UserDetailedNotMe',
|
schema: 'UserDetailedNotMe',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const records = await this.bubbleGameRecordsRepository.find({
|
const records = await this.bubbleGameRecordsRepository.find({
|
||||||
where: {
|
where: {
|
||||||
gameMode: ps.gameMode,
|
gameMode: ps.gameMode,
|
||||||
|
|
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
relations: ['user'],
|
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 => ({
|
return records.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new ApiError(meta.errors.noSuchRoom);
|
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);
|
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);
|
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);
|
throw new ApiError(meta.errors.noSuchRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
visibility: ps.visibility,
|
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)
|
.limit(ps.limit)
|
||||||
.getMany();
|
.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: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
relations: ['user'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userProfile == null) {
|
if (userProfile == null) {
|
||||||
|
|
@ -80,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
|
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.userEntityService.pack(userProfile.user!, userProfile.user!, {
|
return await this.userEntityService.pack(user, user, {
|
||||||
schema: 'MeDetailed',
|
schema: 'MeDetailed',
|
||||||
includeSecrets: isSecure,
|
includeSecrets: isSecure,
|
||||||
userProfile,
|
userProfile,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { AccessTokensRepository } from '@/models/_.js';
|
import type { AccessTokensRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { IdService } from '@/core/IdService.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 = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
@ -46,6 +49,19 @@ export const meta = {
|
||||||
type: 'string',
|
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',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+lastUsedAt', '-lastUsedAt'] },
|
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: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -71,11 +91,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.accessTokensRepository)
|
@Inject(DI.accessTokensRepository)
|
||||||
private accessTokensRepository: AccessTokensRepository,
|
private accessTokensRepository: AccessTokensRepository,
|
||||||
|
|
||||||
|
private readonly userEntityService: UserEntityService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly queryService: QueryService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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 })
|
.where('token.userId = :userId', { userId: me.id })
|
||||||
|
.limit(ps.limit)
|
||||||
.leftJoinAndSelect('token.app', 'app');
|
.leftJoinAndSelect('token.app', 'app');
|
||||||
|
|
||||||
switch (ps.sort) {
|
switch (ps.sort) {
|
||||||
|
|
@ -86,15 +110,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
default: query.orderBy('token.id', 'ASC'); break;
|
default: query.orderBy('token.id', 'ASC'); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.onlySharedAccess) {
|
||||||
|
query.andWhere('token.granteeIds != \'{}\'');
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = await query.getMany();
|
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,
|
id: token.id,
|
||||||
name: token.name ?? token.app?.name,
|
name: token.name ?? token.app?.name,
|
||||||
createdAt: this.idService.parse(token.id).date.toISOString(),
|
createdAt: this.idService.parse(token.id).date.toISOString(),
|
||||||
lastUsedAt: token.lastUsedAt?.toISOString(),
|
lastUsedAt: token.lastUsedAt?.toISOString(),
|
||||||
permission: token.app ? token.app.permission : token.permission,
|
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 { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { AccessTokensRepository } from '@/models/_.js';
|
import type { AccessTokensRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
@ -37,21 +38,31 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.accessTokensRepository)
|
@Inject(DI.accessTokensRepository)
|
||||||
private accessTokensRepository: AccessTokensRepository,
|
private accessTokensRepository: AccessTokensRepository,
|
||||||
|
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
if (ps.tokenId) {
|
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) {
|
if (tokenExist) {
|
||||||
|
for (const granteeId of tokenExist.granteeIds) {
|
||||||
|
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
|
||||||
|
}
|
||||||
|
|
||||||
await this.accessTokensRepository.delete({
|
await this.accessTokensRepository.delete({
|
||||||
id: ps.tokenId,
|
id: ps.tokenId,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (ps.token) {
|
} 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) {
|
if (tokenExist) {
|
||||||
|
for (const granteeId of tokenExist.granteeIds) {
|
||||||
|
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
|
||||||
|
}
|
||||||
|
|
||||||
await this.accessTokensRepository.delete({
|
await this.accessTokensRepository.delete({
|
||||||
token: ps.token,
|
token: ps.token,
|
||||||
userId: me.id,
|
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 { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { AccessTokensRepository } from '@/models/_.js';
|
import type { AccessTokensRepository } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['auth'],
|
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
|
// 10 calls per 5 seconds
|
||||||
limit: {
|
limit: {
|
||||||
duration: 1000 * 5,
|
duration: 1000 * 5,
|
||||||
|
|
@ -46,6 +61,10 @@ export const paramDef = {
|
||||||
permission: { type: 'array', uniqueItems: true, items: {
|
permission: { type: 'array', uniqueItems: true, items: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
} },
|
} },
|
||||||
|
grantees: { type: 'array', uniqueItems: true, items: {
|
||||||
|
type: 'string',
|
||||||
|
} },
|
||||||
|
rank: { type: 'string', enum: ['admin', 'mod', 'user'], nullable: true },
|
||||||
},
|
},
|
||||||
required: ['session', 'permission'],
|
required: ['session', 'permission'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -58,8 +77,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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
|
// Generate access token
|
||||||
const accessToken = secureRndstr(32);
|
const accessToken = secureRndstr(32);
|
||||||
|
|
||||||
|
|
@ -77,8 +112,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
description: ps.description,
|
description: ps.description,
|
||||||
iconUrl: ps.iconUrl,
|
iconUrl: ps.iconUrl,
|
||||||
permission: ps.permission,
|
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', {});
|
this.notificationService.createNotification(me.id, 'createToken', {});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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) {
|
if (!policies.btlAvailable) {
|
||||||
throw new ApiError(meta.errors.btlDisabled);
|
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
|
// TODO inline this into the above query
|
||||||
for (const note of renotes) {
|
for (const note of renotes) {
|
||||||
if (ps.quote) {
|
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 {
|
} 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,
|
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)
|
.limit(ps.limit)
|
||||||
.getMany();
|
.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({
|
const currentCount = await this.userListsRepository.countBy({
|
||||||
userId: me.id,
|
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);
|
throw new ApiError(meta.errors.tooManyUserLists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.limit(ps.limit)
|
.limit(ps.limit)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
return await this.pageEntityService.packMany(pages);
|
return await this.pageEntityService.packMany(pages, me);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export const notificationTypes = [
|
||||||
'scheduledNotePosted',
|
'scheduledNotePosted',
|
||||||
'app',
|
'app',
|
||||||
'test',
|
'test',
|
||||||
|
'sharedAccessGranted',
|
||||||
|
'sharedAccessRevoked',
|
||||||
|
'sharedAccessLogin',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const groupedNotificationTypes = [
|
export const groupedNotificationTypes = [
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { prefer } from '@/preferences.js';
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { signout } from '@/signout.js';
|
import { signout } from '@/signout.js';
|
||||||
|
import * as os from '@/os';
|
||||||
|
|
||||||
type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
|
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']) {
|
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('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
|
||||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
|
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
|
||||||
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
|
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',
|
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> {
|
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
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_exportCompleted]: notification.type === 'exportCompleted',
|
||||||
[$style.t_importCompleted]: notification.type === 'importCompleted',
|
[$style.t_importCompleted]: notification.type === 'importCompleted',
|
||||||
[$style.t_login]: notification.type === 'login',
|
[$style.t_login]: notification.type === 'login',
|
||||||
|
[$style.t_login]: ['sharedAccessGranted', 'sharedAccessRevoked', 'sharedAccessLogin'].includes(notification.type),
|
||||||
[$style.t_createToken]: notification.type === 'createToken',
|
[$style.t_createToken]: notification.type === 'createToken',
|
||||||
[$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived',
|
[$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived',
|
||||||
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
|
[$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_roleAssigned]: notification.type === 'scheduledNoteFailed',
|
||||||
[$style.t_pollEnded]: notification.type === 'scheduledNotePosted',
|
[$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-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 === 'receiveFollowRequest'" class="ti ti-clock"></i>
|
||||||
<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></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 === '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 === '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 === '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使うと一部ブラウザで刺さるので念の為 -->
|
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
|
||||||
<MkReactionIcon
|
<MkReactionIcon
|
||||||
v-else-if="notification.type === 'reaction'"
|
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 === 'edited'">{{ i18n.ts._notification.edited }}</span>
|
||||||
<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</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 === '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"/>
|
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
|
||||||
</header>
|
</header>
|
||||||
<div>
|
<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"/>
|
<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>
|
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
|
||||||
</MkA>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<MkModalWindow
|
<MkModalWindow
|
||||||
ref="dialog"
|
ref="dialog"
|
||||||
:width="400"
|
:width="500"
|
||||||
:height="450"
|
:height="600"
|
||||||
:withOkButton="true"
|
:withOkButton="true"
|
||||||
:okButtonDisabled="false"
|
:okButtonDisabled="false"
|
||||||
:canClose="false"
|
:canClose="false"
|
||||||
|
|
@ -25,29 +25,98 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div>
|
<div>
|
||||||
<MkInput v-model="name">
|
<MkInput v-model="name">
|
||||||
<template #label>{{ i18n.ts.name }}</template>
|
<template #label>{{ i18n.ts.name }}</template>
|
||||||
|
<template #caption>{{ i18n.ts.accessTokenNameDescription }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
</div>
|
</div>
|
||||||
<div><b>{{ i18n.ts.permission }}</b></div>
|
|
||||||
<div class="_buttons">
|
<MkSelect v-if="$i?.isAdmin" v-model="rank">
|
||||||
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
|
<template #label>{{ i18n.ts.overrideRank }}</template>
|
||||||
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
<template #caption>{{ i18n.ts.overrideRankDescription }}</template>
|
||||||
</div>
|
|
||||||
<div class="_gaps_s">
|
<option value="admin">{{ i18n.ts._ranks.admin }}</option>
|
||||||
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
|
<option value="mod">{{ i18n.ts._ranks.mod }}</option>
|
||||||
</div>
|
<option value="user">{{ i18n.ts._ranks.user }}</option>
|
||||||
<div v-if="iAmAdmin" :class="$style.adminPermissions">
|
</MkSelect>
|
||||||
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
|
|
||||||
|
<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">
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTemplateRef, ref } from 'vue';
|
import { useTemplateRef, ref, computed } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import MkInput from './MkInput.vue';
|
import MkInput from './MkInput.vue';
|
||||||
import MkSwitch from './MkSwitch.vue';
|
import MkSwitch from './MkSwitch.vue';
|
||||||
|
|
@ -55,32 +124,52 @@ import MkButton from './MkButton.vue';
|
||||||
import MkInfo from './MkInfo.vue';
|
import MkInfo from './MkInfo.vue';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
information?: string | null;
|
information?: string | null;
|
||||||
initialName?: string | null;
|
initialName?: string | null;
|
||||||
initialPermissions?: (typeof Misskey.permissions)[number][] | null;
|
initialPermissions?: (typeof Misskey.permissions)[number][] | null;
|
||||||
|
withSharedAccess?: boolean | null;
|
||||||
}>(), {
|
}>(), {
|
||||||
title: null,
|
title: null,
|
||||||
information: null,
|
information: null,
|
||||||
initialName: null,
|
initialName: null,
|
||||||
initialPermissions: null,
|
initialPermissions: null,
|
||||||
|
withSharedAccess: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'closed'): void;
|
(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 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 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 dialog = useTemplateRef('dialog');
|
||||||
const name = ref(props.initialName);
|
const name = ref(props.initialName);
|
||||||
const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
|
const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
|
||||||
const permissionSwitchesForAdmin = 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) {
|
if (props.initialPermissions) {
|
||||||
for (const kind of 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', {
|
emit('done', {
|
||||||
name: name.value,
|
name: name.value,
|
||||||
permissions: [
|
permissions: [
|
||||||
...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]),
|
...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();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableAll(): void {
|
async function addGrantee(): Promise<void> {
|
||||||
for (const p in permissionSwitches.value) {
|
const user = await os.selectUser({
|
||||||
permissionSwitches.value[p] = false;
|
localOnly: true,
|
||||||
}
|
});
|
||||||
if (iAmAdmin) {
|
grantees.value.push(user);
|
||||||
for (const p in permissionSwitchesForAdmin.value) {
|
|
||||||
permissionSwitchesForAdmin.value[p] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableAll(): void {
|
function removeGrantee(index: number) {
|
||||||
for (const p in permissionSwitches.value) {
|
grantees.value.splice(index, 1);
|
||||||
permissionSwitches.value[p] = true;
|
|
||||||
}
|
|
||||||
if (iAmAdmin) {
|
|
||||||
for (const p in permissionSwitchesForAdmin.value) {
|
|
||||||
permissionSwitchesForAdmin.value[p] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -147,4 +283,19 @@ function enableAll(): void {
|
||||||
color: var(--MI_THEME-error);
|
color: var(--MI_THEME-error);
|
||||||
background: var(--MI_THEME-panel);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-adaptive-bg :class="[$style.root]">
|
<div v-adaptive-bg :class="[$style.root]">
|
||||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
<MkAvatar :class="$style.avatar" :user="user" indicator :preview="withLink" :link="withLink"/>
|
||||||
<div :class="$style.body">
|
<component :is="withLink ? MkA : 'div'" :class="$style.body" :to="userPage(user)">
|
||||||
<span :class="$style.name"><MkUserName :user="user"/></span>
|
<span :class="$style.name"><MkUserName :user="user"/></span>
|
||||||
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
|
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
|
||||||
</div>
|
</component>
|
||||||
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
|
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -18,14 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||||
|
import MkA from '@/components/global/MkA.vue';
|
||||||
import { misskeyApiGet } from '@/utility/misskey-api.js';
|
import { misskeyApiGet } from '@/utility/misskey-api.js';
|
||||||
import { acct } from '@/filters/user.js';
|
import { acct, userPage } from '@/filters/user.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
user: Misskey.entities.User;
|
user: Misskey.entities.User;
|
||||||
withChart?: boolean;
|
withChart?: boolean;
|
||||||
|
withLink?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
withChart: true,
|
withChart: true,
|
||||||
|
withLink: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartValues = ref<number[] | null>(null);
|
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 * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { $i } from '@/i.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 { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||||
|
|
@ -59,9 +59,20 @@ function addAccount(ev: MouseEvent) {
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.createAccount,
|
text: i18n.ts.createAccount,
|
||||||
action: () => { createAccount(); },
|
action: () => { createAccount(); },
|
||||||
|
}, {
|
||||||
|
text: i18n.ts.sharedAccount,
|
||||||
|
action: () => { addSharedAccount(); },
|
||||||
}], ev.currentTarget ?? ev.target);
|
}], ev.currentTarget ?? ev.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSharedAccount() {
|
||||||
|
getAccountWithSharedAccessDialog().then((res) => {
|
||||||
|
if (res != null) {
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addExistingAccount() {
|
function addExistingAccount() {
|
||||||
getAccountWithSigninDialog().then((res) => {
|
getAccountWithSigninDialog().then((res) => {
|
||||||
if (res != null) {
|
if (res != null) {
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||||
<template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template>
|
<template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template>
|
||||||
</MkKeyValue>
|
</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>
|
</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 #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>
|
<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>
|
</ul>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,6 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
import FormPagination from '@/components/MkPagination.vue';
|
import FormPagination from '@/components/MkPagination.vue';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
@ -58,24 +76,61 @@ import { definePage } from '@/page.js';
|
||||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||||
|
|
||||||
const list = ref<InstanceType<typeof FormPagination>>();
|
const list = ref<InstanceType<typeof FormPagination>>();
|
||||||
|
|
||||||
const pagination = {
|
const props = withDefaults(defineProps<{
|
||||||
endpoint: 'i/apps' as const,
|
onlySharedAccess?: boolean,
|
||||||
|
limit?: number,
|
||||||
|
}>(), {
|
||||||
|
onlySharedAccess: false,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
noPaging: true,
|
});
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
endpoint: 'i/apps' as const,
|
||||||
|
limit: props.limit,
|
||||||
params: {
|
params: {
|
||||||
sort: '+lastUsedAt',
|
sort: '+lastUsedAt',
|
||||||
|
onlySharedAccess: props.onlySharedAccess,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
|
|
||||||
function revoke(token) {
|
async function revoke(token: Misskey.entities.IAppsResponse[number]) {
|
||||||
misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => {
|
const { canceled } = await os.confirm({
|
||||||
list.value?.reload();
|
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 headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = computed(() => []);
|
const headerTabs = computed(() => []);
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,13 @@ const pagination = {
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
|
||||||
done: async result => {
|
done: async result => {
|
||||||
const { name, permissions } = result;
|
const { name, permissions, grantees, rank } = result;
|
||||||
const { token } = await misskeyApi('miauth/gen-token', {
|
const { token } = await misskeyApi('miauth/gen-token', {
|
||||||
session: null,
|
session: null,
|
||||||
name: name,
|
name: name,
|
||||||
permission: permissions,
|
permission: permissions,
|
||||||
|
grantees: grantees,
|
||||||
|
rank: rank,
|
||||||
});
|
});
|
||||||
|
|
||||||
os.alert({
|
os.alert({
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<X2fa/>
|
<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>
|
<FormSection>
|
||||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||||
<MkPagination :pagination="pagination" disableAutoLoad>
|
<MkPagination :pagination="pagination" disableAutoLoad>
|
||||||
|
|
@ -53,8 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||||
import X2fa from './2fa.vue';
|
import X2fa from './2fa.vue';
|
||||||
|
import XApps from '@/pages/settings/apps.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
import FormSlot from '@/components/form/slot.vue';
|
import FormSlot from '@/components/form/slot.vue';
|
||||||
import MkButton from '@/components/MkButton.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 headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = computed(() => []);
|
const headerTabs = computed(() => []);
|
||||||
|
|
|
||||||
|
|
@ -107,13 +107,15 @@ export async function authorizePlugin(plugin: Plugin) {
|
||||||
information: i18n.ts.pluginTokenRequestedDescription,
|
information: i18n.ts.pluginTokenRequestedDescription,
|
||||||
initialName: plugin.name,
|
initialName: plugin.name,
|
||||||
initialPermissions: plugin.permissions,
|
initialPermissions: plugin.permissions,
|
||||||
|
withSharedAccess: false,
|
||||||
}, {
|
}, {
|
||||||
done: async result => {
|
done: async result => {
|
||||||
const { name, permissions } = result;
|
const { name, permissions, rank } = result;
|
||||||
const { token } = await misskeyApi('miauth/gen-token', {
|
const { token } = await misskeyApi('miauth/gen-token', {
|
||||||
session: null,
|
session: null,
|
||||||
name: name,
|
name: name,
|
||||||
permission: permissions,
|
permission: permissions,
|
||||||
|
rank: rank,
|
||||||
});
|
});
|
||||||
res(token);
|
res(token);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1987,6 +1987,10 @@ declare namespace entities {
|
||||||
IRegistryScopesWithDomainResponse,
|
IRegistryScopesWithDomainResponse,
|
||||||
IRegistrySetRequest,
|
IRegistrySetRequest,
|
||||||
IRevokeTokenRequest,
|
IRevokeTokenRequest,
|
||||||
|
ISharedAccessListRequest,
|
||||||
|
ISharedAccessListResponse,
|
||||||
|
ISharedAccessLoginRequest,
|
||||||
|
ISharedAccessLoginResponse,
|
||||||
ISigninHistoryRequest,
|
ISigninHistoryRequest,
|
||||||
ISigninHistoryResponse,
|
ISigninHistoryResponse,
|
||||||
IUnpinRequest,
|
IUnpinRequest,
|
||||||
|
|
@ -2773,6 +2777,18 @@ type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['conten
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
function isAPIError(reason: Record<PropertyKey, unknown>): reason is APIError;
|
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)
|
// @public (undocumented)
|
||||||
type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
|
type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
|
@ -3435,7 +3451,7 @@ type PartialRolePolicyOverride = Partial<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @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)
|
// @public (undocumented)
|
||||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||||
|
|
|
||||||
|
|
@ -3470,6 +3470,30 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): 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.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,10 @@ import type {
|
||||||
IRegistryScopesWithDomainResponse,
|
IRegistryScopesWithDomainResponse,
|
||||||
IRegistrySetRequest,
|
IRegistrySetRequest,
|
||||||
IRevokeTokenRequest,
|
IRevokeTokenRequest,
|
||||||
|
ISharedAccessListRequest,
|
||||||
|
ISharedAccessListResponse,
|
||||||
|
ISharedAccessLoginRequest,
|
||||||
|
ISharedAccessLoginResponse,
|
||||||
ISigninHistoryRequest,
|
ISigninHistoryRequest,
|
||||||
ISigninHistoryResponse,
|
ISigninHistoryResponse,
|
||||||
IUnpinRequest,
|
IUnpinRequest,
|
||||||
|
|
@ -983,6 +987,8 @@ export type Endpoints = {
|
||||||
'i/registry/scopes-with-domain': { req: EmptyRequest; res: IRegistryScopesWithDomainResponse };
|
'i/registry/scopes-with-domain': { req: EmptyRequest; res: IRegistryScopesWithDomainResponse };
|
||||||
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
|
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
|
||||||
'i/revoke-token': { req: IRevokeTokenRequest; 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/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
|
||||||
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
|
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
|
||||||
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
|
'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 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 IRegistrySetRequest = operations['i___registry___set']['requestBody']['content']['application/json'];
|
||||||
export type IRevokeTokenRequest = operations['i___revoke-token']['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 ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
|
||||||
export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['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'];
|
export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
|
||||||
|
|
|
||||||
|
|
@ -2997,6 +2997,26 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['i___revoke-token'];
|
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': {
|
||||||
/**
|
/**
|
||||||
* i/signin-history
|
* i/signin-history
|
||||||
|
|
@ -4585,6 +4605,7 @@ export type components = {
|
||||||
achievements: components['schemas']['Achievement'][];
|
achievements: components['schemas']['Achievement'][];
|
||||||
loggedInDays: number;
|
loggedInDays: number;
|
||||||
policies: components['schemas']['RolePolicies'];
|
policies: components['schemas']['RolePolicies'];
|
||||||
|
permissions: string[];
|
||||||
/** @default false */
|
/** @default false */
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
|
|
@ -5010,6 +5031,39 @@ export type components = {
|
||||||
/** Format: id */
|
/** Format: id */
|
||||||
userId: string;
|
userId: string;
|
||||||
note: components['schemas']['Note'];
|
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 */
|
/** Format: id */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -22709,6 +22763,13 @@ export type operations = {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
sort?: '+createdAt' | '-createdAt' | '+lastUsedAt' | '-lastUsedAt';
|
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) */
|
/** @description OK (with results) */
|
||||||
200: {
|
200: {
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': ({
|
||||||
/** Format: misskey:id */
|
/** Format: misskey:id */
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -22725,7 +22786,10 @@ export type operations = {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
permission: string[];
|
permission: string[];
|
||||||
}[];
|
grantees: components['schemas']['UserLite'][];
|
||||||
|
/** @enum {string|null} */
|
||||||
|
rank: 'admin' | 'mod' | 'user';
|
||||||
|
})[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Client error */
|
/** @description Client error */
|
||||||
|
|
@ -24117,8 +24181,8 @@ export type operations = {
|
||||||
untilId?: string;
|
untilId?: string;
|
||||||
/** @default true */
|
/** @default true */
|
||||||
markAsRead?: boolean;
|
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')[];
|
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' | '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;
|
untilId?: string;
|
||||||
/** @default true */
|
/** @default true */
|
||||||
markAsRead?: boolean;
|
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')[];
|
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' | '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
|
* i/signin-history
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
@ -26304,6 +26502,9 @@ export type operations = {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
permission: string[];
|
permission: string[];
|
||||||
|
grantees?: string[];
|
||||||
|
/** @enum {string|null} */
|
||||||
|
rank?: 'admin' | 'mod' | 'user';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export const permissions = [
|
||||||
'read:admin:meta',
|
'read:admin:meta',
|
||||||
'write:admin:reset-password',
|
'write:admin:reset-password',
|
||||||
'write:admin:resolve-abuse-user-report',
|
'write:admin:resolve-abuse-user-report',
|
||||||
|
'read:admin:abuse-report:notification-recipient',
|
||||||
|
'write:admin:abuse-report:notification-recipient',
|
||||||
'write:admin:send-email',
|
'write:admin:send-email',
|
||||||
'read:admin:server-info',
|
'read:admin:server-info',
|
||||||
'read:admin:show-moderation-log',
|
'read:admin:show-moderation-log',
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,9 @@ _notification:
|
||||||
scheduledNoteFailed: "Posting scheduled note failed"
|
scheduledNoteFailed: "Posting scheduled note failed"
|
||||||
scheduledNotePosted: "Scheduled Note was posted"
|
scheduledNotePosted: "Scheduled Note was posted"
|
||||||
importOfXCompleted: "Import of {x} has been completed"
|
importOfXCompleted: "Import of {x} has been completed"
|
||||||
|
sharedAccessGranted: "Shared access granted"
|
||||||
|
sharedAccessRevoked: "Shared access revoked"
|
||||||
|
sharedAccessLogin: "Shared access login"
|
||||||
_types:
|
_types:
|
||||||
renote: "Boosts"
|
renote: "Boosts"
|
||||||
edited: "Edits"
|
edited: "Edits"
|
||||||
|
|
@ -519,6 +522,8 @@ _permissions:
|
||||||
"write:admin:reject-quotes": "Allow/Prohibit quote posts from a user"
|
"write:admin:reject-quotes": "Allow/Prohibit quote posts from a user"
|
||||||
"read:notes-schedule": "View your list of scheduled notes"
|
"read:notes-schedule": "View your list of scheduled notes"
|
||||||
"write:notes-schedule": "Compose or delete 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"
|
robotsTxt: "Custom robots.txt"
|
||||||
robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey."
|
robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey."
|
||||||
|
|
@ -678,3 +683,41 @@ clearCachedFilesOptions:
|
||||||
customFontSize: "Custom font size"
|
customFontSize: "Custom font size"
|
||||||
|
|
||||||
hideAds: "Hide ads"
|
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