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:
Marie 2025-11-03 23:13:03 +01:00
commit fd0568a7d4
56 changed files with 1600 additions and 109 deletions

166
locales/index.d.ts vendored
View file

@ -9244,6 +9244,14 @@ export interface Locale extends ILocale {
* Compose or delete scheduled notes
*/
"write:notes-schedule": string;
/**
* Read abuse report notification recipients
*/
"read:admin:abuse-report:notification-recipient": string;
/**
* Edit abuse report notification recipients
*/
"write:admin:abuse-report:notification-recipient": string;
};
"_auth": {
/**
@ -10434,6 +10442,18 @@ export interface Locale extends ILocale {
* Import of {x} has been completed
*/
"importOfXCompleted": ParameterizedString<"x">;
/**
* Shared access granted
*/
"sharedAccessGranted": string;
/**
* Shared access revoked
*/
"sharedAccessRevoked": string;
/**
* Shared access login
*/
"sharedAccessLogin": string;
};
"_deck": {
/**
@ -13467,6 +13487,152 @@ export interface Locale extends ILocale {
* Hide ads
*/
"hideAds": string;
/**
* Apps using this token will have no API access except for the functions listed below.
*/
"permissionsDescription": string;
/**
* Apps using this token will have no administrative access except for the functions enabled below.
*/
"adminPermissionsDescription": string;
/**
* Shared account
*/
"sharedAccount": string;
/**
* Shared access
*/
"sharedAccess": string;
/**
* Any accounts listed here will be granted access to the token and may use it to access this account.
*/
"sharedAccessDescription": string;
/**
* Shared access allows another user to access your account without using your password. You may select exactly which features and data are available to guest users.
*/
"sharedAccessDescription2": string;
/**
* Share access
*/
"addGrantee": string;
/**
* Remove access
*/
"removeGrantee": string;
/**
* Login with shared access
*/
"loginWithSharedAccess": string;
/**
* Login with granted access to a shared account
*/
"loginWithSharedAccessDescription": string;
/**
* You have not been granted shared access to any accounts
*/
"noSharedAccess": string;
/**
* Expand
*/
"expand": string;
/**
* Collapse
*/
"collapse": string;
/**
* Permissions
*/
"permissions": string;
/**
* Limit rank
*/
"overrideRank": string;
/**
* Limits the user rank (admin, moderator, or user) for apps using this token.
*/
"overrideRankDescription": string;
/**
* Rank
*/
"rank": string;
"_ranks": {
/**
* Admin
*/
"admin": string;
/**
* Moderator
*/
"mod": string;
/**
* User
*/
"user": string;
/**
* default
*/
"default": string;
};
/**
* Permissions: {num}
*/
"permissionsLabel": ParameterizedString<"num">;
/**
* You have been granted shared access to {target} with {rank} rank and {perms} permissions.
*/
"sharedAccessGranted": ParameterizedString<"target" | "rank" | "perms">;
/**
* Shared access to {target} has been revoked.
*/
"sharedAccessRevoked": ParameterizedString<"target">;
/**
* {target} logged in via shared access.
*/
"sharedAccessLogin": ParameterizedString<"target">;
/**
* Unique name to record the purpose of this access token
*/
"accessTokenNameDescription": string;
/**
* Are you sure you want to revoke this token?
*/
"confirmRevokeToken": string;
/**
* Are you sure you want to revoke this token? {num} shared other users will lose shared access.
*/
"confirmRevokeSharedToken": ParameterizedString<"num">;
/**
* Grant shared access
*/
"grantSharedAccessButton": string;
/**
* No shared access listed
*/
"grantSharedAccessNoSelection": string;
/**
* No shared access users were selected. Please add at least one user in the "shared access" section.
*/
"grantSharedAccessNoSelection2": string;
/**
* Shared access granted
*/
"grantSharedAccessSuccess": string;
/**
* Shared access has been granted to {num} users.
*/
"grantSharedAccessSuccess2": ParameterizedString<"num">;
/**
* Are you sure you want to create a token with no permissions?
*/
"tokenHasNoPermissionsConfirm": string;
/**
* Enable all read-only permissions
*/
"enableAllRead": string;
/**
* Enable all write/edit permissions
*/
"enableAllWrite": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -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"`);
}
}

View file

@ -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`);
}
}

View file

@ -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"`);
}
}

View file

@ -342,11 +342,11 @@ export class ChatService {
}
@bindThis
public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) {
if (await this.isRoomMember(room, meId)) {
public async hasPermissionToViewRoomTimeline(me: MiUser, room: MiChatRoom) {
if (await this.isRoomMember(room, me.id)) {
return true;
} else {
const iAmModerator = await this.roleService.isModerator({ id: meId });
const iAmModerator = await this.roleService.isModerator(me);
if (iAmModerator) {
return true;
}
@ -563,12 +563,12 @@ export class ChatService {
}
@bindThis
public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) {
if (room.ownerId === meId) {
public async hasPermissionToDeleteRoom(me: MiUser, room: MiChatRoom) {
if (room.ownerId === me.id) {
return true;
}
const iAmModerator = await this.roleService.isModerator({ id: meId });
const iAmModerator = await this.roleService.isModerator(me);
if (iAmModerator) {
return true;
}

View file

@ -31,6 +31,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { getCallerId } from '@/misc/attach-caller-id.js';
export type RolePolicies = {
gtlAvailable: boolean;
@ -414,7 +415,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedRoles, ...matchedCondRoles];
let allRoles = [...assignedRoles, ...matchedCondRoles];
// Check for dropped token permissions
const rank = user ? getCallerId(user)?.accessToken?.rank : null;
if (rank != null) {
// Copy roles, since they come from a cache
allRoles = allRoles.map(role => ({
...role,
isModerator: role.isModerator && (rank === 'admin' || rank === 'mod'),
isAdministrator: role.isAdministrator && rank === 'admin',
}));
}
return allRoles;
}
/**
@ -514,12 +529,22 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async isModerator(user: { id: MiUser['id'] } | null): Promise<boolean> {
if (user == null) return false;
// Check for dropped token permissions
const rank = getCallerId(user)?.accessToken?.rank;
if (rank != null && rank !== 'admin' && rank !== 'mod') return false;
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
}
@bindThis
public async isAdministrator(user: { id: MiUser['id'] } | null): Promise<boolean> {
if (user == null) return false;
// Check for dropped token permissions
const rank = getCallerId(user)?.accessToken?.rank;
if (rank != null && rank !== 'admin') return false;
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
}

View file

@ -47,7 +47,7 @@ export class ClipEntityService {
createdAt: this.idService.parse(clip.id).date.toISOString(),
lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null,
userId: clip.userId,
user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId),
user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId, me),
name: clip.name,
description: clip.description,
isPublic: clip.isPublic,

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { In, EntityNotFoundError } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js';
import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository, AccessTokensRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
import type { MiNote } from '@/models/Note.js';
@ -21,7 +21,7 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]);
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted']);
function undefOnMissing<T>(packPromise: Promise<T>): Promise<T | undefined> {
return packPromise.catch(err => {
@ -49,6 +49,9 @@ export class NotificationEntityService implements OnModuleInit {
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.accessTokensRepository)
private readonly accessTokensRepository: AccessTokensRepository,
private cacheService: CacheService,
) {
}
@ -199,6 +202,10 @@ export class NotificationEntityService implements OnModuleInit {
header: notification.customHeader,
icon: notification.customIcon,
} : {}),
...(notification.type === 'sharedAccessGranted' ? {
permCount: notification.permCount,
rank: notification.rank,
} : {}),
});
}

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as Misskey from 'misskey-js';
import _Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core';
import { In } from 'typeorm';
@ -54,6 +55,7 @@ import { ChatService } from '@/core/ChatService.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { CacheService } from '@/core/CacheService.js';
import { getCallerId } from '@/misc/attach-caller-id.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
@ -432,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
userMemos?: Map<MiUser['id'], string | null>,
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
iAmModerator?: boolean,
iAmAdmin?: boolean,
userIdsByUri?: Map<string, string>,
instances?: Map<string, MiInstance | null>,
securityKeyCounts?: Map<string, number>,
@ -477,7 +480,8 @@ export class UserEntityService implements OnModuleInit {
const isDetailed = opts.schema !== 'UserLite';
const meId = me ? me.id : null;
const isMe = meId === user.id;
const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false);
const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me) : false);
const iAmAdmin = opts.iAmAdmin ?? (me ? await this.roleService.isAdministrator(me) : false);
const profile = isDetailed
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
@ -529,8 +533,6 @@ export class UserEntityService implements OnModuleInit {
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
null;
const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null;
const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null;
const unreadAnnouncements = isMe && isDetailed ?
(await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({
createdAt: this.idService.parse(announcement.id).date.toISOString(),
@ -663,8 +665,8 @@ export class UserEntityService implements OnModuleInit {
bannerId: user.bannerId,
backgroundId: user.backgroundId,
followedMessage: profile!.followedMessage,
isModerator: isModerator,
isAdmin: isAdmin,
isModerator: iAmModerator,
isAdmin: iAmAdmin,
isSystem: isSystemAccount(user),
injectFeaturedNote: profile!.injectFeaturedNote,
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
@ -699,6 +701,7 @@ export class UserEntityService implements OnModuleInit {
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
policies: fetchPolicies(),
permissions: this.getPermissions(user, iAmModerator, iAmAdmin),
defaultCW: profile!.defaultCW,
defaultCWPriority: profile!.defaultCWPriority,
allowUnsignedFetch: user.allowUnsignedFetch,
@ -767,7 +770,10 @@ export class UserEntityService implements OnModuleInit {
}
const _userIds = _users.map(u => u.id);
const iAmModerator = await this.roleService.isModerator(me as MiUser);
// Sync with ApiCallService
const iAmAdmin = me ? await this.roleService.isAdministrator(me) : false;
const iAmModerator = me ? await this.roleService.isModerator(me) : false;
const meId = me ? me.id : null;
const isDetailed = options && options.schema !== 'UserLite';
const isDetailedAndMod = isDetailed && iAmModerator;
@ -864,6 +870,7 @@ export class UserEntityService implements OnModuleInit {
userMemos: userMemos,
pinNotes: pinNotes,
iAmModerator,
iAmAdmin,
userIdsByUri,
instances,
securityKeyCounts,
@ -872,4 +879,16 @@ export class UserEntityService implements OnModuleInit {
)),
);
}
@bindThis
private getPermissions(user: MiUser, isModerator: boolean, isAdmin: boolean): readonly string[] {
const token = getCallerId(user);
let permissions = token?.accessToken?.permission ?? Misskey.permissions;
if (!isModerator && !isAdmin) {
permissions = permissions.filter(perm => !perm.startsWith('read:admin') && !perm.startsWith('write:admin'));
}
return permissions;
}
}

View 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];
}

View file

@ -8,6 +8,9 @@ import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiApp } from './App.js';
export const accessTokenRanks = ['user', 'mod', 'admin'] as const;
export type AccessTokenRank = typeof accessTokenRanks[number];
@Entity('access_token')
export class MiAccessToken {
@PrimaryColumn(id())
@ -87,4 +90,25 @@ export class MiAccessToken {
default: false,
})
public fetched: boolean;
@Column('enum', {
enum: accessTokenRanks,
nullable: true,
comment: 'Limits the user\' rank (user, moderator, or admin) when using this token. If null (default), then uses the user\'s actual rank.',
})
public rank: AccessTokenRank | null;
@Index('IDX_access_token_granteeIds', { synchronize: false })
@Column({
...id(),
array: true, default: '{}',
comment: 'IDs of other users who are permitted to access and use this token.',
})
public granteeIds: string[];
public constructor(props?: Partial<MiAccessToken>) {
if (props) {
Object.assign(this, props);
}
}
}

View file

@ -152,6 +152,23 @@ export type MiNotification = {
id: string;
createdAt: string;
noteId: MiNote['id'];
} | {
type: 'sharedAccessGranted';
id: string;
createdAt: string;
notifierId: MiUser['id'];
permCount: number;
rank: 'user' | 'mod' | 'admin' | null;
} | {
type: 'sharedAccessRevoked';
id: string;
createdAt: string;
notifierId: MiUser['id'];
} | {
type: 'sharedAccessLogin';
id: string;
createdAt: string;
notifierId: MiUser['id'];
};
export type MiGroupedNotification = MiNotification | {

View file

@ -327,4 +327,4 @@ export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
export type NoteScheduleRepository = Repository<MiNoteSchedule>;
export type NoteScheduleRepository = Repository<MiNoteSchedule> & MiRepository<MiNoteSchedule>;

View file

@ -460,6 +460,75 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['sharedAccessGranted'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
permCount: {
type: 'number',
optional: true, nullable: false,
},
rank: {
type: 'string',
enum: ['admin', 'mod', 'user'],
optional: true, nullable: true,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['sharedAccessRevoked'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['sharedAccessLogin'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
}, {
type: 'object',
properties: {

View file

@ -739,6 +739,13 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false,
ref: 'RolePolicies',
},
permissions: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
},
},
twoFactorEnabled: {
type: 'boolean',
nullable: false, optional: false,

View file

@ -21,13 +21,13 @@ import type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { renderFullError } from '@/misc/render-full-error.js';
import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
import { renderFullError } from '@/misc/render-full-error.js';
const accessDenied = {
message: 'Access denied.',

View file

@ -13,6 +13,7 @@ import type { MiApp } from '@/models/App.js';
import { CacheService } from '@/core/CacheService.js';
import { isNativeUserToken } from '@/misc/token.js';
import { bindThis } from '@/decorators.js';
import { attachCallerId } from '@/misc/attach-caller-id.js';
export class AuthenticationError extends Error {
constructor(message: string) {
@ -62,6 +63,9 @@ export class AuthenticateService implements OnApplicationShutdown {
}, {
token: token, // miauth
}],
relations: {
user: true,
},
});
if (accessToken == null) {
@ -72,10 +76,11 @@ export class AuthenticateService implements OnApplicationShutdown {
lastUsedAt: new Date(),
});
const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId,
() => this.usersRepository.findOneBy({
id: accessToken.userId,
}) as Promise<MiLocalUser>);
// Loaded by relation above
const user = accessToken.user as MiLocalUser;
// Attach token to user - this will be read by RoleService to drop admin/moderator permissions.
attachCallerId(user, { accessToken });
if (accessToken.appId) {
const app = await this.appCache.fetch(accessToken.appId,

View file

@ -298,6 +298,8 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-
export * as 'i/registry/set' from './endpoints/i/registry/set.js';
export * as 'i/revoke-token' from './endpoints/i/revoke-token.js';
export * as 'i/signin-history' from './endpoints/i/signin-history.js';
export * as 'i/shared-access/list' from './endpoints/i/shared-access/list.js';
export * as 'i/shared-access/login' from './endpoints/i/shared-access/login.js';
export * as 'i/unpin' from './endpoints/i/unpin.js';
export * as 'i/update' from './endpoints/i/update.js';
export * as 'i/update-email' from './endpoints/i/update-email.js';

View file

@ -57,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.userNotFound);
}
const res = await this.userEntityService.pack(profile.user!, null, {
const res = await this.userEntityService.pack(profile.user!, me, {
schema: 'UserDetailedNotMe',
});

View file

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
secret: secret,
});
return await this.appEntityService.pack(app, null, {
return await this.appEntityService.pack(app, me, {
detail: true,
includeSecret: true,
});

View file

@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return {
accessToken: accessToken.token,
user: await this.userEntityService.pack(session.userId, null, {
user: await this.userEntityService.pack(session.userId, me, {
schema: 'UserDetailedNotMe',
}),
};

View file

@ -64,7 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps) => {
super(meta, paramDef, async (ps, me) => {
const records = await this.bubbleGameRecordsRepository.find({
where: {
gameMode: ps.gameMode,
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
relations: ['user'],
});
const users = await this.userEntityService.packMany(records.map(r => r.user!), null);
const users = await this.userEntityService.packMany(records.map(r => r.user!), me);
return records.map(r => ({
id: r.id,

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRoom);
}
if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) {
if (!await this.chatService.hasPermissionToViewRoomTimeline(me, room)) {
throw new ApiError(meta.errors.noSuchRoom);
}

View file

@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRoom);
}
if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) {
if (!await this.chatService.hasPermissionToDeleteRoom(me, room)) {
throw new ApiError(meta.errors.noSuchRoom);
}

View file

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
visibility: ps.visibility,
});
return await this.flashEntityService.pack(flash);
return await this.flashEntityService.pack(flash, me);
});
}
}

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit)
.getMany();
return await this.flashEntityService.packMany(flashs);
return await this.flashEntityService.packMany(flashs, me);
});
}
}

View file

@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
where: {
userId: user.id,
},
relations: ['user'],
});
if (userProfile == null) {
@ -80,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
}
return await this.userEntityService.pack(userProfile.user!, userProfile.user!, {
return await this.userEntityService.pack(user, user, {
schema: 'MeDetailed',
includeSecrets: isSecure,
userProfile,

View file

@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js';
export const meta = {
requireCredential: true,
@ -46,6 +49,19 @@ export const meta = {
type: 'string',
},
},
grantees: {
type: 'array',
optional: false,
items: {
ref: 'UserLite',
},
},
rank: {
type: 'string',
optional: false,
nullable: true,
enum: ['admin', 'mod', 'user'],
},
},
},
},
@ -61,6 +77,10 @@ export const paramDef = {
type: 'object',
properties: {
sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+lastUsedAt', '-lastUsedAt'] },
onlySharedAccess: { type: 'boolean' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
@ -71,11 +91,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
private readonly userEntityService: UserEntityService,
private readonly cacheService: CacheService,
private readonly queryService: QueryService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.accessTokensRepository.createQueryBuilder('token')
const query = this.queryService.makePaginationQuery(this.accessTokensRepository.createQueryBuilder('token'), ps.sinceId, ps.untilId)
.where('token.userId = :userId', { userId: me.id })
.limit(ps.limit)
.leftJoinAndSelect('token.app', 'app');
switch (ps.sort) {
@ -86,15 +110,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
default: query.orderBy('token.id', 'ASC'); break;
}
if (ps.onlySharedAccess) {
query.andWhere('token.granteeIds != \'{}\'');
}
const tokens = await query.getMany();
return await Promise.all(tokens.map(token => ({
const users = await this.cacheService.getUsers(tokens.flatMap(token => token.granteeIds));
const packedUsers = await this.userEntityService.packMany(Array.from(users.values()), me);
const packedUserMap = new Map(packedUsers.map(u => [u.id, u]));
return tokens.map(token => ({
id: token.id,
name: token.name ?? token.app?.name,
createdAt: this.idService.parse(token.id).date.toISOString(),
lastUsedAt: token.lastUsedAt?.toISOString(),
permission: token.app ? token.app.permission : token.permission,
})));
rank: token.rank,
grantees: token.granteeIds
.map(id => packedUserMap.get(id))
.filter(user => user != null),
}));
});
}
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
export const meta = {
requireCredential: true,
@ -37,21 +38,31 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
private readonly notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.tokenId) {
const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
const tokenExist = await this.accessTokensRepository.findOne({ where: { id: ps.tokenId } });
if (tokenExist) {
for (const granteeId of tokenExist.granteeIds) {
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
}
await this.accessTokensRepository.delete({
id: ps.tokenId,
userId: me.id,
});
}
} else if (ps.token) {
const tokenExist = await this.accessTokensRepository.exists({ where: { token: ps.token } });
const tokenExist = await this.accessTokensRepository.findOne({ where: { token: ps.token } });
if (tokenExist) {
for (const granteeId of tokenExist.granteeIds) {
this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id);
}
await this.accessTokensRepository.delete({
token: ps.token,
userId: me.id,

View file

@ -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,
}));
});
}
}

View file

@ -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,
};
});
}
}

View file

@ -4,12 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { ApiError } from '@/server/api/error.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['auth'],
@ -29,6 +31,19 @@ export const meta = {
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a',
},
mustBeLocal: {
message: 'Grantee must be local',
code: 'MUST_BE_LOCAL',
id: '403c73e5-6f03-41b4-9394-ac128947f7ae',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
@ -46,6 +61,10 @@ export const paramDef = {
permission: { type: 'array', uniqueItems: true, items: {
type: 'string',
} },
grantees: { type: 'array', uniqueItems: true, items: {
type: 'string',
} },
rank: { type: 'string', enum: ['admin', 'mod', 'user'], nullable: true },
},
required: ['session', 'permission'],
} as const;
@ -58,8 +77,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private notificationService: NotificationService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
// Validate grantees
if (ps.grantees && ps.grantees.length > 0) {
const grantees = await this.cacheService.getUsers(ps.grantees);
if (grantees.size !== ps.grantees.length) {
throw new ApiError(meta.errors.noSuchUser);
}
for (const grantee of grantees.values()) {
if (grantee.host != null) {
throw new ApiError(meta.errors.mustBeLocal);
}
}
}
// Generate access token
const accessToken = secureRndstr(32);
@ -77,8 +112,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: ps.description,
iconUrl: ps.iconUrl,
permission: ps.permission,
rank: ps.rank,
granteeIds: ps.grantees,
});
if (ps.grantees) {
for (const granteeId of ps.grantees) {
this.notificationService.createNotification(granteeId, 'sharedAccessGranted', { permCount: ps.permission.length, rank: ps.rank ?? null }, me.id);
}
}
// アクセストークンが生成されたことを通知
this.notificationService.createNotification(me.id, 'createToken', {});

View file

@ -68,7 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
const policies = await this.roleService.getUserPolicies(me);
if (!policies.btlAvailable) {
throw new ApiError(meta.errors.btlDisabled);
}

View file

@ -69,9 +69,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// TODO inline this into the above query
for (const note of renotes) {
if (ps.quote) {
if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
if (note.text) this.noteDeleteService.delete(me, note, false);
} else {
if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
if (!note.text) this.noteDeleteService.delete(me, note, false);
}
}
});

View file

@ -119,7 +119,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
font: ps.font,
}));
return await this.pageEntityService.pack(page);
return await this.pageEntityService.pack(page, me);
});
}
}

View file

@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.limit(ps.limit)
.getMany();
return await this.flashEntityService.packMany(flashs);
return await this.flashEntityService.packMany(flashs, me);
});
}
}

View file

@ -106,7 +106,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const currentCount = await this.userListsRepository.countBy({
userId: me.id,
});
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) {
if (currentCount >= (await this.roleService.getUserPolicies(me)).userListLimit) {
throw new ApiError(meta.errors.tooManyUserLists);
}

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit)
.getMany();
return await this.pageEntityService.packMany(pages);
return await this.pageEntityService.packMany(pages, me);
});
}
}

View file

@ -46,6 +46,9 @@ export const notificationTypes = [
'scheduledNotePosted',
'app',
'test',
'sharedAccessGranted',
'sharedAccessRevoked',
'sharedAccessLogin',
] as const;
export const groupedNotificationTypes = [

View file

@ -16,6 +16,7 @@ import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { $i } from '@/i.js';
import { signout } from '@/signout.js';
import * as os from '@/os';
type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
@ -39,7 +40,18 @@ export async function getAccounts(): Promise<{
}
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
// Check for duplicate accounts
if (prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
if (store.s.accountTokens[host + '/' + user.id] !== token) {
// Replace account if the token changed
await removeAccount(host, user.id);
} else {
console.debug(`Not adding account ${host}/${user.id}: already logged in with same token.`);
return;
}
}
{
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
@ -299,6 +311,15 @@ export async function openAccountMenu(opts: {
}
});
},
}, {
text: i18n.ts.sharedAccount,
action: () => {
getAccountWithSharedAccessDialog().then((res) => {
if (res != null) {
os.success();
}
});
},
}],
}, {
type: 'link',
@ -324,6 +345,24 @@ export async function openAccountMenu(opts: {
});
}
export function getAccountWithSharedAccessDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/SkSigninSharedAccessDialog.vue')), {}, {
done: async (res: { id: string, i: string }) => {
const user = await fetchAccount(res.i, res.id, true);
await addAccount(host, user, res.i);
resolve({ id: res.id, token: res.i });
},
cancelled: () => {
resolve(null);
},
closed: () => {
dispose();
},
});
});
}
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {

View file

@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_importCompleted]: notification.type === 'importCompleted',
[$style.t_login]: notification.type === 'login',
[$style.t_login]: ['sharedAccessGranted', 'sharedAccessRevoked', 'sharedAccessLogin'].includes(notification.type),
[$style.t_createToken]: notification.type === 'createToken',
[$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
@ -34,7 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
[$style.t_pollEnded]: notification.type === 'scheduledNotePosted',
}]"
> <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
>
<!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
<i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i>
<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i>
@ -56,6 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-calendar-event"></i>
<i v-else-if="notification.type === 'sharedAccessGranted'" class="ph-door-open ph-bold pg-lg"></i>
<i v-else-if="notification.type === 'sharedAccessRevoked'" class="ph-lock ph-bold pg-lg"></i>
<i v-else-if="notification.type === 'sharedAccessLogin'" class="ph-sign-in ph-bold pg-lg"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon
v-else-if="notification.type === 'reaction'"
@ -86,6 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
<span v-else-if="notification.type === 'sharedAccessGranted'">{{ i18n.ts._notification.sharedAccessGranted }}</span>
<span v-else-if="notification.type === 'sharedAccessRevoked'">{{ i18n.ts._notification.sharedAccessRevoked }}</span>
<span v-else-if="notification.type === 'sharedAccessLogin'">{{ i18n.ts._notification.sharedAccessLogin }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
<div>
@ -191,6 +199,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA>
<div v-else-if="notification.type === 'sharedAccessGranted'">
<MkA :to="userPage(notification.user)">
<I18n :src="i18n.ts.sharedAccessGranted" tag="span">
<template #target><MkAcct :user="notification.user"/></template>
<template #rank>{{ i18n.ts._ranks[notification.rank ?? 'default'] }}</template>
<template #perms>{{ notification.permCount ?? 0 }}</template>
</I18n>
</MkA>
</div>
<MkA v-else-if="notification.type === 'sharedAccessRevoked'" :to="userPage(notification.user)">
<I18n :src="i18n.ts.sharedAccessRevoked" tag="span">
<template #target><MkAcct :user="notification.user"/></template>
</I18n>
</MkA>
<MkA v-else-if="notification.type === 'sharedAccessLogin'" :to="userPage(notification.user)">
<I18n :src="i18n.ts.sharedAccessLogin" tag="span">
<template #target><MkAcct :user="notification.user"/></template>
</I18n>
</MkA>
</div>
</div>
</div>

View file

@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
:width="500"
:height="600"
:withOkButton="true"
:okButtonDisabled="false"
:canClose="false"
@ -25,29 +25,98 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
<template #caption>{{ i18n.ts.accessTokenNameDescription }}</template>
</MkInput>
</div>
<div><b>{{ i18n.ts.permission }}</b></div>
<div class="_buttons">
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div>
<div v-if="iAmAdmin" :class="$style.adminPermissions">
<div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div>
<MkSelect v-if="$i?.isAdmin" v-model="rank">
<template #label>{{ i18n.ts.overrideRank }}</template>
<template #caption>{{ i18n.ts.overrideRankDescription }}</template>
<option value="admin">{{ i18n.ts._ranks.admin }}</option>
<option value="mod">{{ i18n.ts._ranks.mod }}</option>
<option value="user">{{ i18n.ts._ranks.user }}</option>
</MkSelect>
<MkSelect v-else v-model="rank">
<template #label>{{ i18n.ts.overrideRank }}</template>
<template #caption>{{ i18n.ts.overrideRankDescription }}</template>
<option value="mod">{{ i18n.ts._ranks.mod }}</option>
<option value="user">{{ i18n.ts._ranks.user }}</option>
</MkSelect>
<MkFolder v-if="withSharedAccess !== false" :defaultOpen="withSharedAccess === true">
<template #label>{{ i18n.ts.sharedAccess }}</template>
<template #suffix>{{ grantees.length || i18n.ts.none }}</template>
<div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
<div>{{ i18n.ts.sharedAccessDescription }}</div>
<MkButton primary @click="addGrantee">
<i class="ti ti-plus"></i> {{ i18n.ts.addGrantee }}
</MkButton>
<div v-for="(grantee, i) of grantees" :key="grantee.id" :class="$style.grantee">
<MkUserCardMini :user="grantee" :withChart="false"/>
<button v-tooltip="i18n.ts.removeGrantee" class="_textButton" @click="() => removeGrantee(i)"><i class="ti ti-x"></i></button>
</div>
</div>
</div>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts.permission }}</template>
<template #suffix>{{ permsCount || i18n.ts.none }}</template>
<div class="_gaps">
<div>{{ i18n.ts.permissionsDescription }}</div>
<div class="_gaps_s">
<MkSwitch v-model="enableAllRead">{{ i18n.ts.enableAllRead }}</MkSwitch>
<MkSwitch v-model="enableAllWrite">{{ i18n.ts.enableAllWrite }}</MkSwitch>
</div>
<div :class="$style.divider"></div>
<div class="_gaps_s">
<MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch>
</div>
</div>
</MkFolder>
<MkFolder v-if="iAmAdmin || iAmModerator">
<template #label>{{ i18n.ts.adminPermission }}</template>
<template #suffix>{{ adminPermsCount || i18n.ts.none }}</template>
<div class="_gaps">
<div>{{ i18n.ts.adminPermissionsDescription }}</div>
<div class="_gaps_s">
<MkSwitch v-model="enableAllReadAdmin" :disabled="rank === 'user'">{{ i18n.ts.enableAllRead }}</MkSwitch>
<MkSwitch v-model="enableAllWriteAdmin" :disabled="rank === 'user'">{{ i18n.ts.enableAllWrite }}</MkSwitch>
</div>
<div :class="$style.divider"></div>
<div class="_gaps_s">
<MkSwitch
v-for="kind in Object.keys(permissionSwitchesForAdmin)"
:key="kind"
v-model="permissionSwitchesForAdmin[kind]"
:disabled="rank === 'user'"
>
{{ i18n.ts._permissions[kind] }}
</MkSwitch>
</div>
</div>
</MkFolder>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { useTemplateRef, ref } from 'vue';
import { useTemplateRef, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkInput from './MkInput.vue';
import MkSwitch from './MkSwitch.vue';
@ -55,32 +124,52 @@ import MkButton from './MkButton.vue';
import MkInfo from './MkInfo.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { iAmAdmin } from '@/i.js';
import { $i, iAmAdmin, iAmModerator } from '@/i.js';
import MkFolder from '@/components/MkFolder.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
const props = withDefaults(defineProps<{
title?: string | null;
information?: string | null;
initialName?: string | null;
initialPermissions?: (typeof Misskey.permissions)[number][] | null;
withSharedAccess?: boolean | null;
}>(), {
title: null,
information: null,
initialName: null,
initialPermissions: null,
withSharedAccess: null,
});
const emit = defineEmits<{
(ev: 'closed'): void;
(ev: 'done', result: { name: string | null, permissions: string[] }): void;
(ev: 'done', result: { name: string | null, permissions: string[], grantees: string[], rank: string }): void;
}>();
const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin'));
const defaultReadPermissions = defaultPermissions.filter(p => p.startsWith('read:'));
const defaultWritePermissions = defaultPermissions.filter(p => p.startsWith('write:'));
const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
const adminReadPermissions = adminPermissions.filter(p => p.startsWith('read:'));
const adminWritePermissions = adminPermissions.filter(p => p.startsWith('write:'));
const dialog = useTemplateRef('dialog');
const name = ref(props.initialName);
const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
const grantees = ref<Misskey.entities.User[]>([]);
const rank = ref<'admin' | 'mod' | 'user'>(
$i?.isAdmin
? 'admin'
: $i?.isModerator
? 'mod'
: 'user');
const permsCount = computed(() => Object.values(permissionSwitches.value).reduce((sum, active) => active ? sum + 1 : sum, 0));
const adminPermsCount = computed(() => Object.values(permissionSwitchesForAdmin.value).reduce((sum, active) => active ? sum + 1 : sum, 0));
if (props.initialPermissions) {
for (const kind of props.initialPermissions) {
@ -98,37 +187,84 @@ if (props.initialPermissions) {
}
}
function ok(): void {
const enableAllRead = computed({
get() {
return defaultReadPermissions.every(p => permissionSwitches.value[p]);
},
set(value: boolean) {
defaultReadPermissions.forEach(p => permissionSwitches.value[p] = value);
},
});
const enableAllWrite = computed({
get() {
return defaultWritePermissions.every(p => permissionSwitches.value[p]);
},
set(value: boolean) {
defaultWritePermissions.forEach(p => permissionSwitches.value[p] = value);
},
});
const enableAllReadAdmin = computed({
get() {
return adminReadPermissions.every(p => permissionSwitchesForAdmin.value[p]);
},
set(value: boolean) {
adminReadPermissions.forEach(p => permissionSwitchesForAdmin.value[p] = value);
},
});
const enableAllWriteAdmin = computed({
get() {
return adminWritePermissions.every(p => permissionSwitchesForAdmin.value[p]);
},
set(value: boolean) {
adminWritePermissions.forEach(p => permissionSwitchesForAdmin.value[p] = value);
},
});
async function ok(): Promise<void> {
if (props.withSharedAccess === true && grantees.value.length < 1) {
await os.alert({
type: 'warning',
title: i18n.ts.grantSharedAccessNoSelection,
text: i18n.ts.grantSharedAccessNoSelection2,
});
return;
}
if (!Object.values(permissionSwitches.value).some(v => v) && !Object.values(permissionSwitchesForAdmin.value).some(v => v)) {
const { canceled } = await os.confirm({
type: 'question',
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
text: i18n.ts.tokenHasNoPermissionsConfirm,
});
if (canceled) return;
}
emit('done', {
name: name.value,
permissions: [
...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]),
...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []),
...((iAmAdmin && rank.value === 'admin') ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []),
],
grantees: grantees.value.map(g => g.id),
rank: rank.value,
});
dialog.value?.close();
}
function disableAll(): void {
for (const p in permissionSwitches.value) {
permissionSwitches.value[p] = false;
}
if (iAmAdmin) {
for (const p in permissionSwitchesForAdmin.value) {
permissionSwitchesForAdmin.value[p] = false;
}
}
async function addGrantee(): Promise<void> {
const user = await os.selectUser({
localOnly: true,
});
grantees.value.push(user);
}
function enableAll(): void {
for (const p in permissionSwitches.value) {
permissionSwitches.value[p] = true;
}
if (iAmAdmin) {
for (const p in permissionSwitchesForAdmin.value) {
permissionSwitchesForAdmin.value[p] = true;
}
}
function removeGrantee(index: number) {
grantees.value.splice(index, 1);
}
</script>
@ -147,4 +283,19 @@ function enableAll(): void {
color: var(--MI_THEME-error);
background: var(--MI_THEME-panel);
}
.grantee {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--MI-marginHalf);
}
.grantee > :first-child {
flex: 1;
}
.divider {
border-bottom: 1px solid var(--MI_THEME-divider);
}
</style>

View file

@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-adaptive-bg :class="[$style.root]">
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.body">
<MkAvatar :class="$style.avatar" :user="user" indicator :preview="withLink" :link="withLink"/>
<component :is="withLink ? MkA : 'div'" :class="$style.body" :to="userPage(user)">
<span :class="$style.name"><MkUserName :user="user"/></span>
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
</div>
</component>
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
</div>
</template>
@ -18,14 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
import * as Misskey from 'misskey-js';
import { onMounted, ref } from 'vue';
import MkMiniChart from '@/components/MkMiniChart.vue';
import MkA from '@/components/global/MkA.vue';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import { acct } from '@/filters/user.js';
import { acct, userPage } from '@/filters/user.js';
const props = withDefaults(defineProps<{
user: Misskey.entities.User;
withChart?: boolean;
withLink?: boolean;
}>(), {
withChart: true,
withLink: false,
});
const chartValues = ref<number[] | null>(null);

View 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>

View file

@ -24,7 +24,7 @@ import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i } from '@/i.js';
import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js';
import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccountWithSharedAccessDialog } from '@/accounts.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
@ -59,9 +59,20 @@ function addAccount(ev: MouseEvent) {
}, {
text: i18n.ts.createAccount,
action: () => { createAccount(); },
}, {
text: i18n.ts.sharedAccount,
action: () => { addSharedAccount(); },
}], ev.currentTarget ?? ev.target);
}
function addSharedAccount() {
getAccountWithSharedAccessDialog().then((res) => {
if (res != null) {
os.success();
}
});
}
function addExistingAccount() {
getAccountWithSigninDialog().then((res) => {
if (res != null) {

View file

@ -32,12 +32,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #key>{{ i18n.ts.lastUsedDate }}</template>
<template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template>
</MkKeyValue>
<MkKeyValue v-if="token.rank" oneline>
<template #key>{{ i18n.ts.rank }}</template>
<template #value>{{ i18n.ts._ranks[token.rank ?? 'default'] ?? token.rank }}</template>
</MkKeyValue>
</div>
<MkFolder>
<MkFolder v-if="token.grantees.length > 0" :defaultOpen="onlySharedAccess">
<template #label>{{ i18n.ts.sharedAccess }}</template>
<template #suffix>{{ token.grantees.length }}</template>
<MkUserCardMini v-for="grantee of token.grantees" :key="grantee.id" :user="grantee" :withChart="true" :withLink="true"/>
</MkFolder>
<MkFolder v-if="standardPerms(token.permission).length > 0">
<template #label>{{ i18n.ts.permission }}</template>
<template #suffix>{{ Object.keys(token.permission).length === 0 ? i18n.ts.none : Object.keys(token.permission).length }}</template>
<template #suffix>{{ standardPerms(token.permission).length }}</template>
<ul>
<li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
<li v-for="p of standardPerms(token.permission)" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</MkFolder>
<MkFolder v-if="adminPerms(token.permission).length > 0">
<template #label>{{ i18n.ts.adminPermission }}</template>
<template #suffix>{{ adminPerms(token.permission).length }}</template>
<ul>
<li v-for="p of adminPerms(token.permission)" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</MkFolder>
</div>
@ -51,6 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import FormPagination from '@/components/MkPagination.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
@ -58,24 +76,61 @@ import { definePage } from '@/page.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
const list = ref<InstanceType<typeof FormPagination>>();
const pagination = {
endpoint: 'i/apps' as const,
const props = withDefaults(defineProps<{
onlySharedAccess?: boolean,
limit?: number,
}>(), {
onlySharedAccess: false,
limit: 100,
noPaging: true,
});
const pagination = computed(() => ({
endpoint: 'i/apps' as const,
limit: props.limit,
params: {
sort: '+lastUsedAt',
onlySharedAccess: props.onlySharedAccess,
},
};
}));
function revoke(token) {
misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => {
list.value?.reload();
async function revoke(token: Misskey.entities.IAppsResponse[number]) {
const { canceled } = await os.confirm({
type: 'question',
text: token.grantees.length > 0
? i18n.tsx.confirmRevokeSharedToken({ num: token.grantees.length })
: i18n.ts.confirmRevokeToken,
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
});
if (canceled) return;
await os.promiseDialog(async () => {
await misskeyApi('i/revoke-token', { tokenId: token.id });
await list.value?.reload();
});
}
function isAdmin(perm: string): boolean {
return perm.startsWith('read:admin') || perm.startsWith('write:admin');
}
function standardPerms(perms: string[]): string[] {
return perms.filter(perm => !isAdmin(perm));
}
function adminPerms(perms: string[]): string[] {
return perms.filter(perm => isAdmin(perm));
}
defineExpose({
reload: () => list.value?.reload(),
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

View file

@ -84,11 +84,13 @@ const pagination = {
function generateToken() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
done: async result => {
const { name, permissions } = result;
const { name, permissions, grantees, rank } = result;
const { token } = await misskeyApi('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
grantees: grantees,
rank: rank,
});
os.alert({

View file

@ -24,6 +24,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<X2fa/>
<SearchMarker :keywords="['shared', 'access']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.sharedAccess }}</SearchLabel></template>
<template #description>{{ i18n.ts.sharedAccessDescription2 }}</template>
<div class="_gaps_m">
<MkButton primary @click="grantSharedAccess">{{ i18n.ts.grantSharedAccessButton }}</MkButton>
<XApps ref="apps" :onlySharedAccess="true" :limit="10"/>
</div>
</FormSection>
</SearchMarker>
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
<MkPagination :pagination="pagination" disableAutoLoad>
@ -53,8 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
import X2fa from './2fa.vue';
import XApps from '@/pages/settings/apps.vue';
import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';
@ -113,6 +127,36 @@ async function regenerateToken() {
});
}
const apps = useTemplateRef('apps');
function grantSharedAccess() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
withSharedAccess: true,
}, {
done: async result => {
const { name, permissions, grantees, rank } = result;
await os.promiseDialog(async () => {
await misskeyApi('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
grantees: grantees,
rank: rank,
});
await apps.value?.reload();
});
await os.alert({
type: 'success',
title: i18n.ts.grantSharedAccessSuccess,
text: i18n.tsx.grantSharedAccessSuccess2({ num: grantees.length }),
});
},
closed: () => dispose(),
});
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

View file

@ -107,13 +107,15 @@ export async function authorizePlugin(plugin: Plugin) {
information: i18n.ts.pluginTokenRequestedDescription,
initialName: plugin.name,
initialPermissions: plugin.permissions,
withSharedAccess: false,
}, {
done: async result => {
const { name, permissions } = result;
const { name, permissions, rank } = result;
const { token } = await misskeyApi('miauth/gen-token', {
session: null,
name: name,
permission: permissions,
rank: rank,
});
res(token);
},

View file

@ -1987,6 +1987,10 @@ declare namespace entities {
IRegistryScopesWithDomainResponse,
IRegistrySetRequest,
IRevokeTokenRequest,
ISharedAccessListRequest,
ISharedAccessListResponse,
ISharedAccessLoginRequest,
ISharedAccessLoginResponse,
ISigninHistoryRequest,
ISigninHistoryResponse,
IUnpinRequest,
@ -2773,6 +2777,18 @@ type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['conten
// @public (undocumented)
function isAPIError(reason: Record<PropertyKey, unknown>): reason is APIError;
// @public (undocumented)
type ISharedAccessListRequest = operations['i___shared-access___list']['requestBody']['content']['application/json'];
// @public (undocumented)
type ISharedAccessListResponse = operations['i___shared-access___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISharedAccessLoginRequest = operations['i___shared-access___login']['requestBody']['content']['application/json'];
// @public (undocumented)
type ISharedAccessLoginResponse = operations['i___shared-access___login']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
@ -3435,7 +3451,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:cw-note", "write:admin:cw-instance", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "read:admin:abuse-report:notification-recipient", "write:admin:abuse-report:notification-recipient", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:cw-note", "write:admin:cw-instance", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -3470,6 +3470,30 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/shared-access/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/shared-access/login', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -462,6 +462,10 @@ import type {
IRegistryScopesWithDomainResponse,
IRegistrySetRequest,
IRevokeTokenRequest,
ISharedAccessListRequest,
ISharedAccessListResponse,
ISharedAccessLoginRequest,
ISharedAccessLoginResponse,
ISigninHistoryRequest,
ISigninHistoryResponse,
IUnpinRequest,
@ -983,6 +987,8 @@ export type Endpoints = {
'i/registry/scopes-with-domain': { req: EmptyRequest; res: IRegistryScopesWithDomainResponse };
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
'i/shared-access/list': { req: ISharedAccessListRequest; res: ISharedAccessListResponse };
'i/shared-access/login': { req: ISharedAccessLoginRequest; res: ISharedAccessLoginResponse };
'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
'i/update': { req: IUpdateRequest; res: IUpdateResponse };

View file

@ -465,6 +465,10 @@ export type IRegistryRemoveRequest = operations['i___registry___remove']['reques
export type IRegistryScopesWithDomainResponse = operations['i___registry___scopes-with-domain']['responses']['200']['content']['application/json'];
export type IRegistrySetRequest = operations['i___registry___set']['requestBody']['content']['application/json'];
export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json'];
export type ISharedAccessListRequest = operations['i___shared-access___list']['requestBody']['content']['application/json'];
export type ISharedAccessListResponse = operations['i___shared-access___list']['responses']['200']['content']['application/json'];
export type ISharedAccessLoginRequest = operations['i___shared-access___login']['requestBody']['content']['application/json'];
export type ISharedAccessLoginResponse = operations['i___shared-access___login']['responses']['200']['content']['application/json'];
export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json'];
export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];

View file

@ -2997,6 +2997,26 @@ export type paths = {
*/
post: operations['i___revoke-token'];
};
'/i/shared-access/list': {
/**
* i/shared-access/list
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___shared-access___list'];
};
'/i/shared-access/login': {
/**
* i/shared-access/login
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___shared-access___login'];
};
'/i/signin-history': {
/**
* i/signin-history
@ -4585,6 +4605,7 @@ export type components = {
achievements: components['schemas']['Achievement'][];
loggedInDays: number;
policies: components['schemas']['RolePolicies'];
permissions: string[];
/** @default false */
twoFactorEnabled: boolean;
/** @default false */
@ -5010,6 +5031,39 @@ export type components = {
/** Format: id */
userId: string;
note: components['schemas']['Note'];
} | ({
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'sharedAccessGranted';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
permCount?: number;
/** @enum {string|null} */
rank?: 'admin' | 'mod' | 'user';
}) | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'sharedAccessRevoked';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'sharedAccessLogin';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
} | {
/** Format: id */
id: string;
@ -22709,6 +22763,13 @@ export type operations = {
'application/json': {
/** @enum {string} */
sort?: '+createdAt' | '-createdAt' | '+lastUsedAt' | '-lastUsedAt';
onlySharedAccess?: boolean;
/** @default 30 */
limit?: number;
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
};
};
};
@ -22716,7 +22777,7 @@ export type operations = {
/** @description OK (with results) */
200: {
content: {
'application/json': {
'application/json': ({
/** Format: misskey:id */
id: string;
name?: string;
@ -22725,7 +22786,10 @@ export type operations = {
/** Format: date-time */
lastUsedAt?: string;
permission: string[];
}[];
grantees: components['schemas']['UserLite'][];
/** @enum {string|null} */
rank: 'admin' | 'mod' | 'user';
})[];
};
};
/** @description Client error */
@ -24117,8 +24181,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
};
};
};
@ -24185,8 +24249,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'importCompleted' | 'login' | 'createToken' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'sharedAccessGranted' | 'sharedAccessRevoked' | 'sharedAccessLogin' | 'pollVote' | 'groupInvited')[];
};
};
};
@ -25151,6 +25215,140 @@ export type operations = {
};
};
};
/**
* i/shared-access/list
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
'i___shared-access___list': {
requestBody: {
content: {
'application/json': {
/** @default 30 */
limit?: number;
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': ({
id: string;
user: components['schemas']['UserLite'];
permissions: string[];
/** @enum {string|null} */
rank: 'admin' | 'mod' | 'user';
})[];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* i/shared-access/login
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
'i___shared-access___login': {
requestBody: {
content: {
'application/json': {
grantId: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
userId: string;
token: string;
};
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* i/signin-history
* @description No description provided.
@ -26304,6 +26502,9 @@ export type operations = {
description?: string | null;
iconUrl?: string | null;
permission: string[];
grantees?: string[];
/** @enum {string|null} */
rank?: 'admin' | 'mod' | 'user';
};
};
};

View file

@ -71,6 +71,8 @@ export const permissions = [
'read:admin:meta',
'write:admin:reset-password',
'write:admin:resolve-abuse-user-report',
'read:admin:abuse-report:notification-recipient',
'write:admin:abuse-report:notification-recipient',
'write:admin:send-email',
'read:admin:server-info',
'read:admin:show-moderation-log',

View file

@ -329,6 +329,9 @@ _notification:
scheduledNoteFailed: "Posting scheduled note failed"
scheduledNotePosted: "Scheduled Note was posted"
importOfXCompleted: "Import of {x} has been completed"
sharedAccessGranted: "Shared access granted"
sharedAccessRevoked: "Shared access revoked"
sharedAccessLogin: "Shared access login"
_types:
renote: "Boosts"
edited: "Edits"
@ -519,6 +522,8 @@ _permissions:
"write:admin:reject-quotes": "Allow/Prohibit quote posts from a user"
"read:notes-schedule": "View your list of scheduled notes"
"write:notes-schedule": "Compose or delete scheduled notes"
"read:admin:abuse-report:notification-recipient": "Read abuse report notification recipients"
"write:admin:abuse-report:notification-recipient": "Edit abuse report notification recipients"
robotsTxt: "Custom robots.txt"
robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey."
@ -678,3 +683,41 @@ clearCachedFilesOptions:
customFontSize: "Custom font size"
hideAds: "Hide ads"
permissionsDescription: "Apps using this token will have no API access except for the functions listed below."
adminPermissionsDescription: "Apps using this token will have no administrative access except for the functions enabled below."
sharedAccount: "Shared account"
sharedAccess: "Shared access"
sharedAccessDescription: "Any accounts listed here will be granted access to the token and may use it to access this account."
sharedAccessDescription2: "Shared access allows another user to access your account without using your password. You may select exactly which features and data are available to guest users."
addGrantee: "Share access"
removeGrantee: "Remove access"
loginWithSharedAccess: "Login with shared access"
loginWithSharedAccessDescription: "Login with granted access to a shared account"
noSharedAccess: "You have not been granted shared access to any accounts"
expand: "Expand"
collapse: "Collapse"
permissions: "Permissions"
overrideRank: "Limit rank"
overrideRankDescription: "Limits the user rank (admin, moderator, or user) for apps using this token."
rank: "Rank"
_ranks:
admin: "Admin"
mod: "Moderator"
user: "User"
default: "default"
permissionsLabel: "Permissions: {num}"
sharedAccessGranted: "You have been granted shared access to {target} with {rank} rank and {perms} permissions."
sharedAccessRevoked: "Shared access to {target} has been revoked."
sharedAccessLogin: "{target} logged in via shared access."
accessTokenNameDescription: "Unique name to record the purpose of this access token"
confirmRevokeToken: "Are you sure you want to revoke this token?"
confirmRevokeSharedToken: "Are you sure you want to revoke this token? {num} shared other users will lose shared access."
grantSharedAccessButton: "Grant shared access"
grantSharedAccessNoSelection: "No shared access listed"
grantSharedAccessNoSelection2: "No shared access users were selected. Please add at least one user in the \"shared access\" section."
grantSharedAccessSuccess: "Shared access granted"
grantSharedAccessSuccess2: "Shared access has been granted to {num} users."
tokenHasNoPermissionsConfirm: "Are you sure you want to create a token with no permissions?"
enableAllRead: "Enable all read-only permissions"
enableAllWrite: "Enable all write/edit permissions"