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