Просмотр исходного кода

Merge pull request #9751 from weseek/feat/162830-choose-scope-of-access-token

Feat/162830 choose scope of access token
reiji-h 1 год назад
Родитель
Сommit
e410fca79e

+ 20 - 22
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -3,10 +3,10 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 
-
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import type { Scope } from '~/interfaces/scope';
-import { SCOPE } from '~/interfaces/scope';
+
+import { AccessTokenScopeSelect } from './AccessTokenScopeSelect';
 
 const MAX_DESCRIPTION_LENGTH = 200;
 
@@ -17,11 +17,9 @@ type AccessTokenFormProps = {
 type FormInputs = {
   expiredAt: string;
   description: string;
-  // TODO: Implement scope selection
   scopes: Scope[];
 }
 
-// TODO: Implement scope selection
 export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
   const { submitHandler } = props;
   const { t } = useTranslation();
@@ -35,10 +33,12 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
     register,
     handleSubmit,
     formState: { errors, isValid },
+    watch,
   } = useForm<FormInputs>({
     defaultValues: {
       expiredAt: defaultExpiredAtStr,
       description: '',
+      scopes: [],
     },
   });
 
@@ -111,25 +111,23 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
           </div>
 
           <div className="mb-3">
-            <label htmlFor="scope" className="form-label">{t('page_me_access_token.scope')}</label>
-            <div className="form-text mb-2">{t('page_me_access_token.form.scope_desc')}</div>
-            <div className="form-text mb-2">
-              <input
-                type="checkbox"
-                id="scope-read-user"
-                value={SCOPE.READ.USER.ALL}
-                {...register('scopes')}
-              />
-              <label htmlFor="scope-read-user" className="ms-2">Read User</label>
-            </div>
+            <label htmlFor="scopes" className="form-label">
+              {t('page_me_access_token.scope')}
+            </label>
+            <AccessTokenScopeSelect
+              selectedScopes={watch('scopes')}
+              register={register('scopes', {
+                required: t('input_validation.message.required', { param: t('page_me_access_token.scope') }),
+              })}
+            />
+            {errors.scopes && (
+              <div className="invalid-feedback">
+                {errors.scopes.message}
+              </div>
+            )}
+
             <div className="form-text mb-2">
-              <input
-                type="checkbox"
-                id="scope-write-user"
-                value={SCOPE.WRITE.USER.ALL}
-                {...register('scopes')}
-              />
-              <label htmlFor="scope-write-user" className="ms-2">Write User</label>
+              {t('page_me_access_token.form.scope_desc')}
             </div>
           </div>
 

+ 36 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.module.scss

@@ -0,0 +1,36 @@
+$baseMargin: 20px;
+
+.access-token-scope-list :global {
+  .indentation {
+    &.indentation-level-1 {
+      margin-left: $baseMargin;
+    }
+    &.indentation-level-2 {
+      margin-left: $baseMargin * 2;
+    }
+    &.indentation-level-3 {
+      margin-left: $baseMargin * 3;
+    }
+    &.indentation-level-4 {
+      margin-left: $baseMargin * 4;
+    }
+    &.indentation-level-5 {
+      margin-left: $baseMargin * 5;
+    }
+    &.indentation-level-6 {
+      margin-left: $baseMargin * 6;
+    }
+    &.indentation-level-7 {
+      margin-left: $baseMargin * 7;
+    }
+    &.indentation-level-8 {
+      margin-left: $baseMargin * 8;
+    }
+    &.indentation-level-9 {
+      margin-left: $baseMargin * 9;
+    }
+    &.indentation-level-10 {
+      margin-left: $baseMargin * 10;
+    }
+  }
+}

+ 89 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -0,0 +1,89 @@
+import React from 'react';
+
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { useIsDeviceLargerThanMd } from '~/stores/ui';
+
+import type { Scope } from '../../../interfaces/scope';
+
+import styles from './AccessTokenScopeList.module.scss';
+
+const moduleClass = styles['access-token-scope-list'] ?? '';
+
+interface scopeObject {
+  [key: string]: Scope | scopeObject;
+}
+
+interface AccessTokenScopeListProps {
+  scopeObject: scopeObject;
+  register: UseFormRegisterReturn<'scopes'>;
+  disabledScopes: Set<Scope>
+  level?: number;
+}
+
+/**
+ * Renders the permission object recursively as nested checkboxes.
+ */
+export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
+  scopeObject,
+  register,
+  disabledScopes,
+  level = 0,
+}) => {
+
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+
+  // Convert object into an array to determine "first vs. non-first" elements
+  const entries = Object.entries(scopeObject);
+
+  return (
+    <>
+      {entries.map(([scopeKey, scopeValue], idx) => {
+        // Get indentation class based on level
+        // Example: Insert <hr> only for levels 0 or 1, except for the first item
+        const showHr = (level === 0 || level === 1) && idx !== 0;
+
+        if (typeof scopeValue === 'object') {
+          return (
+            <div key={scopeKey} className={moduleClass}>
+              {showHr && <hr className="my-1" />}
+              <div className="my-1 row">
+                <div className="col-md-5 ">
+                  <label className={`form-check-label fw-bold indentation indentation-level-${level}`}>{scopeKey}</label>
+                </div>
+                <div className={`col form-text fw-bold ${isDeviceLargerThanMd ? '' : 'text-end'}`}>desc for {scopeKey}</div>
+              </div>
+
+              {/* Render recursively */}
+              <AccessTokenScopeList
+                scopeObject={scopeValue as scopeObject}
+                register={register}
+                level={level + 1}
+                disabledScopes={disabledScopes}
+              />
+            </div>
+          );
+        }
+        // If it's a string, render a checkbox
+        return (
+          <div key={scopeKey} className={`row my-1 ${moduleClass}`}>
+            <div className="col-md-5 indentation">
+              <input
+                className={`form-check-input indentation indentation-level-${level}`}
+                type="checkbox"
+                id={scopeValue as string}
+                disabled={disabledScopes.has(scopeValue)}
+                value={scopeValue as string}
+                {...register}
+              />
+              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+                {scopeKey}
+              </label>
+            </div>
+            <div className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}>desc for {scopeKey}</div>
+          </div>
+        );
+      })}
+    </>
+  );
+};

+ 42 - 0
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -0,0 +1,42 @@
+import React, { useEffect, useState, useMemo } from 'react';
+
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
+import { useIsAdmin } from '~/stores-universal/context';
+
+import type { Scope } from '../../../interfaces/scope';
+import { SCOPE } from '../../../interfaces/scope';
+
+import { AccessTokenScopeList } from './AccessTokenScopeList';
+
+/**
+ * Props for AccessTokenScopeSelect
+ */
+type AccessTokenScopeSelectProps = {
+  /** React Hook Form's register function for a field named "scopes" */
+  register: UseFormRegisterReturn<'scopes'>;
+  selectedScopes: Scope[];
+};
+
+/**
+ * Displays a list of permissions in a recursive, nested checkbox interface.
+ */
+export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
+  const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
+  const { data: isAdmin } = useIsAdmin();
+
+  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
+
+  useEffect(() => {
+    const disabledSet = getDisabledScopes(selectedScopes, extractedScopes);
+    setDisabledScopes(disabledSet);
+  }, [selectedScopes, extractedScopes]);
+
+  return (
+    <div className="border rounded">
+      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+    </div>
+  );
+};

+ 133 - 0
apps/app/src/client/util/scope-util.test.ts

@@ -0,0 +1,133 @@
+import { describe, it, expect } from 'vitest';
+
+import { ALL_SIGN } from '../../interfaces/scope';
+
+import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+
+describe('scope-util', () => {
+
+  const mockScopes = {
+    READ: {
+      USER: 'read:user',
+      ADMIN: {
+        SETTING: 'read:admin:setting',
+        ALL: 'read:admin:all',
+      },
+      ALL: 'read:all',
+    },
+    WRITE: {
+      USER: 'write:user',
+      ADMIN: {
+        SETTING: 'write:admin:setting',
+        ALL: 'write:admin:all',
+      },
+      ALL: 'write:all',
+    },
+  };
+
+  it('should parse scopes correctly for non-admin', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: false });
+
+    // Check that admin scopes are excluded
+    expect(result.ADMIN).toBeUndefined();
+    expect(result.ALL).toBeUndefined();
+
+    // Check that user scopes are included
+    expect(result.USER).toBeDefined();
+    expect(result.USER['read:user']).toBe('read:user');
+    expect(result.USER['write:user']).toBe('write:user');
+  });
+
+  it('should include admin scopes for admin users', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: true });
+
+    // Check that admin scopes are included
+    expect(result.ADMIN).toBeDefined();
+    expect(result.ALL).toBeDefined();
+
+    // Check admin settings
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+
+    // Check ALL category
+    expect(result.ALL['read:all']).toBe('read:all');
+    expect(result.ALL['write:all']).toBe('write:all');
+  });
+
+  it('should return empty set when no scopes are selected', () => {
+    const result = getDisabledScopes([], ['read:user', 'write:user']);
+    expect(result.size).toBe(0);
+  });
+
+  it('should disable specific scopes when a wildcard is selected', () => {
+    const selectedScopes = [`read:${ALL_SIGN}`];
+    const availableScopes = ['read:user', 'read:admin', 'write:user', `read:${ALL_SIGN}`];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all read: scopes except the wildcard itself
+    expect(result.has('read:user')).toBe(true);
+    expect(result.has('read:admin')).toBe(true);
+    expect(result.has(`read:${ALL_SIGN}`)).toBe(false);
+    expect(result.has('write:user')).toBe(false);
+  });
+
+  it('should handle multiple wildcard selections', () => {
+    const selectedScopes = [`read:${ALL_SIGN}`, `write:${ALL_SIGN}`];
+    const availableScopes = [
+      'read:user', 'read:admin', `read:${ALL_SIGN}`,
+      'write:user', 'write:admin', `write:${ALL_SIGN}`,
+    ];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all specific scopes under both wildcards
+    expect(result.has('read:user')).toBe(true);
+    expect(result.has('read:admin')).toBe(true);
+    expect(result.has('write:user')).toBe(true);
+    expect(result.has('write:admin')).toBe(true);
+    expect(result.has(`read:${ALL_SIGN}`)).toBe(false);
+    expect(result.has(`write:${ALL_SIGN}`)).toBe(false);
+  });
+
+  it('should extract all scope strings from a nested object', () => {
+    const scopeObj = {
+      USER: {
+        'read:user': 'read:user',
+        'write:user': 'write:user',
+      },
+      ADMIN: {
+        'ADMIN:SETTING': {
+          'read:admin:setting': 'read:admin:setting',
+          'write:admin:setting': 'write:admin:setting',
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+
+    expect(result).toContain('read:user');
+    expect(result).toContain('write:user');
+    expect(result).toContain('read:admin:setting');
+    expect(result).toContain('write:admin:setting');
+    expect(result.length).toBe(4);
+  });
+
+  it('should return empty array for empty object', () => {
+    const result = extractScopes({});
+    expect(result).toEqual([]);
+  });
+
+  it('should handle objects with no string values', () => {
+    const scopeObj = {
+      level1: {
+        level2: {
+          level3: {},
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+    expect(result).toEqual([]);
+  });
+});

+ 143 - 0
apps/app/src/client/util/scope-util.ts

@@ -0,0 +1,143 @@
+import type { Scope } from '~/interfaces/scope';
+import { ALL_SIGN } from '~/interfaces/scope';
+
+// Data structure for the final merged scopes
+interface ScopeMap {
+  [key: string]: Scope | ScopeMap;
+}
+
+// Input object with arbitrary action keys (e.g., READ, WRITE)
+type ScopesInput = Record<string, any>;
+
+
+function parseSubScope(
+    parentKey: string,
+    subObjForActions: Record<string, any>,
+    actions: string[],
+): ScopeMap {
+  const result: ScopeMap = {};
+
+  for (const action of actions) {
+    if (typeof subObjForActions[action] === 'string') {
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      subObjForActions[action] = undefined;
+    }
+  }
+
+  const childKeys = new Set<string>();
+  for (const action of actions) {
+    const obj = subObjForActions[action];
+    if (obj && typeof obj === 'object') {
+      Object.keys(obj).forEach(k => childKeys.add(k));
+    }
+  }
+
+  for (const ck of childKeys) {
+    if (ck === 'ALL') {
+      for (const action of actions) {
+        const val = subObjForActions[action]?.[ck];
+        if (typeof val === 'string') {
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      continue;
+    }
+
+    const newKey = `${parentKey}:${ck}`;
+    const childSubObj: Record<string, any> = {};
+    for (const action of actions) {
+      childSubObj[action] = subObjForActions[action]?.[ck];
+    }
+
+    result[newKey] = parseSubScope(newKey, childSubObj, actions);
+  }
+
+  return result;
+}
+
+export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+  const actions = Object.keys(scopes);
+  const topKeys = new Set<string>();
+
+  // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
+  for (const action of actions) {
+    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+  }
+
+  const result: ScopeMap = {};
+
+  for (const key of topKeys) {
+    // Skip 'ADMIN' key if isAdmin is true
+    if (!isAdmin && (key === 'ADMIN' || key === 'ALL')) {
+      continue;
+    }
+
+    if (key === 'ALL') {
+      const allObj: ScopeMap = {};
+      for (const action of actions) {
+        const val = scopes[action]?.[key];
+        if (typeof val === 'string') {
+          allObj[`${action.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      result.ALL = allObj;
+    }
+    else {
+      const subObjForActions: Record<string, any> = {};
+      for (const action of actions) {
+        subObjForActions[action] = scopes[action]?.[key];
+      }
+      result[key] = parseSubScope(key, subObjForActions, actions);
+    }
+  }
+
+  return result;
+}
+
+/**
+ * Determines which scopes should be disabled based on wildcard selections
+ */
+export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+  const disabledSet = new Set<Scope>();
+
+
+  // If no selected scopes, return empty set
+  if (!selectedScopes || selectedScopes.length === 0) {
+    return disabledSet;
+  }
+
+  selectedScopes.forEach((scope) => {
+    // Check if the scope is in the form `xxx:*`
+    if (scope.endsWith(`:${ALL_SIGN}`)) {
+      // Convert something like `read:*` into the prefix `read:`
+      const prefix = scope.replace(`:${ALL_SIGN}`, ':');
+
+      // Disable all scopes that start with the prefix (but are not the selected scope itself)
+      availableScopes.forEach((s: Scope) => {
+        if (s.startsWith(prefix) && s !== scope) {
+          disabledSet.add(s);
+        }
+      });
+    }
+  });
+
+  return disabledSet;
+}
+
+/**
+ * Extracts all scope strings from a nested ScopeMap object
+ */
+export function extractScopes(obj: Record<string, any>): string[] {
+  let result: string[] = [];
+
+  Object.values(obj).forEach((value) => {
+    if (typeof value === 'string') {
+      result.push(value);
+    }
+    else if (typeof value === 'object' && !Array.isArray(value)) {
+      result = result.concat(extractScopes(value));
+    }
+  });
+
+  return result;
+}