NaokiHigashi28 1 год назад
Родитель
Сommit
ac4450d4c8

+ 1 - 3
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -5,7 +5,6 @@ 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';
 
@@ -44,12 +43,11 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
 
   const onSubmit = (data: FormInputs) => {
     const expiredAtDate = new Date(data.expiredAt);
-    const scope: Scope[] = data.scopes ? data.scopes : [];
 
     submitHandler({
       expiredAt: expiredAtDate,
       description: data.description,
-      scope: data.scopes, // scope の値を正しく送信
+      scope: data.scopes,
     });
   };
 

+ 152 - 108
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -1,18 +1,49 @@
 import React from 'react';
 
 import type { UseFormRegisterReturn } from 'react-hook-form';
+import { v4 as uuid } from 'uuid';
 
 import { SCOPE } from '../../../interfaces/scope';
 
-const scopes = [
-  { id: 'admin', label: 'admin', desc: 'Access admin data' },
-  { id: 'user', label: 'user', desc: 'Access user data' },
-];
+/** Top-level READ/WRITE structure for scope definitions */
+interface ScopePermissions {
+  READ?: PermissionBranch;
+  WRITE?: PermissionBranch;
+  [key: string]: unknown;
+}
+
+/** Nested permission object, e.g. { ADMIN: { ALL: "read:admin:*", TOP: { ... } } } */
+interface PermissionBranch {
+  [key: string]: PermissionBranch | string;
+}
 
+/** The merged node includes optional read/write strings plus any nested keys */
+interface MergedNode {
+  read?: string;
+  write?: string;
+  [key: string]: MergedNode | string | undefined;
+}
+
+/**
+ * After we transform the merged tree, we end up with a structure
+ * whose keys can be "read:xyz", "write:xyz", or uppercase subkeys
+ * that point to further nesting.
+ */
+interface TransformedNode {
+  [key: string]: string | TransformedNode;
+}
+
+/**
+ * Props for AccessTokenScopeSelect
+ */
 type AccessTokenScopeSelectProps = {
+  /** React Hook Form's register function for a field named "scopes" */
   register: UseFormRegisterReturn<'scopes'>;
 };
 
+/**
+ * Displays a list of permissions in a recursive, nested checkbox interface.
+ */
 export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register }) => {
   return (
     <div className="border rounded">
@@ -21,44 +52,72 @@ export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
   );
 };
 
-const RecursiveScopeList = ({ scopeObject, register, level = 0 }) => {
+
+function generateKeys(count: number): string[] {
+  return Array.from({ length: count }, () => uuid());
+}
+
+const IndentationSpans: React.FC<{ count: number }> = ({ count }) => {
+  const [spanKeys, setSpanKeys] = React.useState<string[]>(() => generateKeys(count));
+
+  React.useEffect(() => {
+    setSpanKeys(generateKeys(count));
+  }, [count]);
+
   return (
     <>
-      {Object.entries(scopeObject).map(([key, value], index) => {
-        // string か object かを判定
-        const isNestedObject = typeof value === 'object';
+      {spanKeys.map(k => (
+        <span key={k} className="ms-3" />
+      ))}
+    </>
+  );
+};
 
-        // 階層に応じた offset クラスを動的に付与
-        // 例: level=1 なら offset-md-1, level=2 なら offset-md-2,...
-        // 大きくなりすぎないように適宜制限をかけてもOK
-        const offsetLevel = Math.min(level, 5); // たとえば最大5まで
-        const offsetClass = offsetLevel > 0 ? `offset-md-${level}` : '';
 
+interface RecursiveScopeListProps {
+  /** A node in the final transformed permission structure */
+  scopeObject: TransformedNode;
+  /** React Hook Form's register function for a field named "scopes" */
+  register: UseFormRegisterReturn<'scopes'>;
+  /** Depth level used for indentation (default 0) */
+  level?: number;
+}
+
+/**
+ * Renders the permission object recursively as nested checkboxes.
+ */
+const RecursiveScopeList: React.FC<RecursiveScopeListProps> = ({
+  scopeObject,
+  register,
+  level = 0,
+}) => {
+  // Convert object into an array so we can detect "first vs. not-first"
+  const entries = Object.entries(scopeObject);
+
+  return (
+    <>
+      {entries.map(([scopeKey, scopeValue], idx) => {
+        const isNestedObject = typeof scopeValue === 'object' && !Array.isArray(scopeValue);
+
+        const showHr = (level === 0 || level === 1) && idx !== 0;
 
         if (isNestedObject) {
-          // 子要素がオブジェクト(=さらにネストされる場合)
+          // If the child is an object, display label, optional <hr>, and recurse
           return (
-            <div key={key}>
+            <div key={scopeKey}>
+              {showHr && <hr className="my-1" />}
+
               <div className="my-1 row">
-                {(level === 0 || level === 1) && index !== 0 && <hr className="m-0" />}
-                {/* チェックボックス + ラベル */}
-                <div className={`col-md-5 ${offsetClass}`}>
-                  <input
-                    className="form-check-input ms-2"
-                    type="checkbox"
-                    id={key}
-                    value={key}
-                  />
-                  <label className="form-check-label fw-bold ms-2" htmlFor={key}>
-                    {key}
-                  </label>
-                </div>
-                <div className={`col-md-4 offset-md-${3 - level}`}>
-                  desc
+                <div className="col-md-5">
+                  <IndentationSpans count={level + 1} />
+                  <label className="form-check-label fw-bold">{scopeKey}</label>
                 </div>
+                <div className="col fs-6 text-secondary">desc for {scopeKey}</div>
               </div>
+
+              {/* Recurse into the nested object */}
               <RecursiveScopeList
-                scopeObject={value}
+                scopeObject={scopeValue as TransformedNode}
                 register={register}
                 level={level + 1}
               />
@@ -66,27 +125,24 @@ const RecursiveScopeList = ({ scopeObject, register, level = 0 }) => {
           );
         }
 
-        // 子要素が単なる文字列(葉ノード)の場合
+        // If the child is a string, it's a leaf checkbox
         return (
-          <div key={key} className="row my-1">
-            {/* チェックボックス + ラベル */}
-            <div className={`col-md-5 ${offsetClass}`}>
+          <div key={scopeKey} className="row my-1">
+            <div className="col-md-5">
+              <IndentationSpans count={level + 1} />
+
               <input
-                className="form-check-input ms-2"
+                className="form-check-input"
                 type="checkbox"
-                id={value}
-                value={value}
+                id={scopeValue as string}
+                value={scopeValue as string}
                 {...register}
               />
-              <label className="form-check-label ms-2" htmlFor={value}>
-                {value}
+              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+                {scopeValue as string}
               </label>
             </div>
-
-            {/* 説明などをそろえて表示する列 */}
-            <div className={`col-md-4 offset-md-${3 - level}`}>
-              desc
-            </div>
+            <div className="col fs-6 text-secondary">desc for {scopeValue as string}</div>
           </div>
         );
       })}
@@ -94,96 +150,81 @@ const RecursiveScopeList = ({ scopeObject, register, level = 0 }) => {
   );
 };
 
+/**
+ * Build an intermediate tree structure merging READ/WRITE branches.
+ * "ALL" keys in nested levels merge into the parent node’s read/write.
+ */
+function buildMergedTree(permissions: ScopePermissions): MergedNode {
+  const root: MergedNode = {};
 
-// ------------------------------------------------------------
-// 2) 中間ツリーを作る
-//    ここで「ALL」というキーがあった場合、サブノードを作らず
-//    親に「read:hoge:all」などを直接格納する処理を行う
-// ------------------------------------------------------------
-function buildMergedTree(permissions: Record<string, any>) {
-  const root: Record<string, any> = {};
-
+  // Recursively traverse each subtree under a READ or WRITE key
   function traverse(
-      obj: Record<string, any>, // 今見ている階層のオブジェクト
-      action: 'read' | 'write', // "read" または "write"
-      path: string[], // ["admin","top"] のような階層パス
+      obj: PermissionBranch,
+      action: 'read' | 'write',
+      path: string[],
   ) {
     for (const [key, value] of Object.entries(obj)) {
-      const lowerKey = key.toLowerCase(); // 例: "ALL" → "all"
+      const lowerKey = key.toLowerCase();
 
-      // value が文字列 ⇒ 葉ノード ("read:user:info" など)
+      // Leaf node is a string like "read:admin:*"
       if (typeof value === 'string') {
-        // 「ALL」で、かつ現在 path が空でなければ (=トップレベル以外) 親ノードにマージ
-        // ※ 「ADMIN:ALL」などを作らず、親ノードに直接 read/write を入れる
+        // If the key is "ALL" and we're not at the top level, merge directly into the parent
         if (lowerKey === 'all' && path.length > 0) {
           const parentNode = getOrCreateNode(root, path);
           parentNode[action] = value;
         }
         else {
-          // 通常はキーに対応するサブノードを作成してセット
-          const node = getOrCreateNode(root, path.concat(lowerKey));
+          // Otherwise, create/obtain the subnode and set read/write
+          const node = getOrCreateNode(root, [...path, lowerKey]);
           node[action] = value;
         }
       }
-      // value がオブジェクト ⇒ 再帰的に下の階層へ
+
+      // If it’s another object, recurse deeper
       else if (value && typeof value === 'object') {
         if (lowerKey === 'all' && path.length > 0) {
-          // さらに深い階層も "ALL" に続く場合は、そのまま同じ path にマージする
-          traverse(value, action, path);
+          // If deeper levels under "ALL", merge them into the same path
+          traverse(value as PermissionBranch, action, path);
         }
         else {
-          traverse(value, action, path.concat(lowerKey));
+          traverse(value as PermissionBranch, action, [...path, lowerKey]);
         }
       }
     }
   }
 
-  // パスに沿ってノードを作成 or 取得
-  function getOrCreateNode(base: Record<string, any>, segments: string[]) {
+  // Helper to walk the path array and create/fetch a node in the root
+  function getOrCreateNode(base: MergedNode, segments: string[]): MergedNode {
     let curr = base;
     for (const seg of segments) {
       if (!curr[seg]) {
         curr[seg] = {};
       }
-      curr = curr[seg];
+      curr = curr[seg] as MergedNode;
     }
     return curr;
   }
 
-  // トップレベルにある "READ" / "WRITE" を処理
+  // Process top-level READ/WRITE objects
   for (const [actionKey, subtree] of Object.entries(permissions)) {
     const action = actionKey.toLowerCase() === 'read' ? 'read' : 'write';
-    traverse(subtree, action, []);
+    if (subtree && typeof subtree === 'object') {
+      traverse(subtree as PermissionBranch, action, []);
+    }
   }
 
   return root;
 }
 
-
-// ------------------------------------------------------------
-// 3) 中間ツリーを「read:hoge」「write:hoge:xxx」形式に変換
-// ------------------------------------------------------------
 /**
- * 変換イメージ:
- *  node = {
- *    read: "read:admin:*",
- *    write: "write:admin:*",
- *    top: { read: "read:admin:top", write: "write:admin:top" },
- *    app: { ... }
- *  }
- *  path = "admin"
- *
- *  => 出力: {
- *       "read:admin": "read:admin:*",
- *       "write:admin": "write:admin:*",
- *       "ADMIN:TOP": { "read:admin:top": ..., "write:admin:top": ... },
- *       "ADMIN:APP": { ... }
- *     }
+ * Convert the merged tree to final structure:
+ * - Insert "read:xyz" / "write:xyz" as keys for each node
+ * - Uppercase sub-node keys for further nesting
  */
-function transformTree(node: Record<string, any>, path: string): Record<string, any> {
-  const result: Record<string, any> = {};
+function transformTree(node: MergedNode, path: string): TransformedNode {
+  const result: TransformedNode = {};
 
-  // read / write があれば 「read:パス」 「write:パス」を設定
+  // If the node has read/write, assign them as new keys
   if (node.read) {
     result[`read:${path}`] = node.read;
   }
@@ -191,33 +232,36 @@ function transformTree(node: Record<string, any>, path: string): Record<string,
     result[`write:${path}`] = node.write;
   }
 
-  // サブノードはキー名を大文字化して再帰的に処理
+  // For each nested key, transform recursively
   for (const [k, v] of Object.entries(node)) {
     if (k === 'read' || k === 'write') continue;
 
-    const subPath = path ? `${path}:${k}` : k; // 例: path="admin", k="top" → "admin:top"
-    const upperKey = `${path ? `${path}:` : ''}${k}`.toUpperCase();
-    // 例: "admin:top" → "ADMIN:TOP"
+    const subPath = path ? `${path}:${k}` : k;
+    const upperKey = path ? `${path}:${k}`.toUpperCase() : k.toUpperCase();
 
-    result[upperKey] = transformTree(v, subPath);
+    if (typeof v === 'object' && v !== null) {
+      result[upperKey] = transformTree(v as MergedNode, subPath);
+    }
   }
-
   return result;
 }
 
-
-// ------------------------------------------------------------
-// 4) 最終的に parsePermissions でまとめる
-// ------------------------------------------------------------
-function parsePermissions(permissions: Record<string, any>) {
-  // まず中間ツリーを作成
+/**
+ * Main function that:
+ * 1) Merges the SCOPE’s READ/WRITE objects into one intermediate tree.
+ * 2) Transforms that intermediate tree into an object of type { [key: string]: string | object }.
+ */
+function parsePermissions(permissions: ScopePermissions): Record<string, TransformedNode> {
   const merged = buildMergedTree(permissions);
+  const result: Record<string, TransformedNode> = {};
 
-  // トップレベル (all, admin, user, base...) を transform
-  const result: Record<string, any> = {};
+  // Transform each top-level key of the merged tree
   for (const [topKey, node] of Object.entries(merged)) {
     const upperKey = topKey.toUpperCase();
-    result[upperKey] = transformTree(node, topKey);
+    if (typeof node === 'object' && node !== null) {
+      result[upperKey] = transformTree(node as MergedNode, topKey);
+    }
   }
+
   return result;
 }