forked from baron/baron-sso
Compare commits
708 Commits
feature/i1
...
stage/CDC
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad1743758 | ||
|
|
5376baf6d8 | ||
|
|
7ecb19e397 | ||
| 44a853408e | |||
| 081cd6739a | |||
| 7fd750b587 | |||
| 26180ae5d1 | |||
| 9072bbc42d | |||
| f810427b21 | |||
| 8e28a9d74b | |||
| cfba44cec2 | |||
| 81f4ddb2b4 | |||
| 2ee1ee4037 | |||
| 487ed20286 | |||
| 991577258b | |||
| 97fee9dbae | |||
| c40202f502 | |||
| 9e73059d2a | |||
| 5d334069c7 | |||
| 685923a03e | |||
| 2216d9c4e4 | |||
| 4dc274a5d7 | |||
| 4139bb7064 | |||
| 18e9a2aa4a | |||
| 7ab79a8bc3 | |||
| b05700f7cc | |||
| 750776f0a0 | |||
| 797e6cc90a | |||
| a1d516cd61 | |||
| 7f955e2122 | |||
| 4427ab1f85 | |||
| ae03fe1475 | |||
| e7156450ba | |||
| 0f79b7635b | |||
| 1024ad17d3 | |||
| 141c8e0ab5 | |||
| 1f464b60a4 | |||
| ea387ff6f2 | |||
| 7e0680a71c | |||
| e15de6d334 | |||
| 51e46a4d00 | |||
| 0b8eaec636 | |||
| 2a9b044992 | |||
| 6322ff5630 | |||
| a79c350831 | |||
| f955d23ef1 | |||
| f494d8e50a | |||
| 034789b8cb | |||
| 8d0982b89c | |||
| dd93a3450a | |||
| 91299b1a0a | |||
| 8f7c328d22 | |||
| 790f006f93 | |||
| 6b93cc945a | |||
| 55be717ff6 | |||
| de2c684096 | |||
| b757a137c3 | |||
|
|
114f203ecd | ||
| a9a448e7fb | |||
| 582591e532 | |||
| ad5a49b62f | |||
| 9f3506c530 | |||
| acab84c358 | |||
| b72d04f184 | |||
| c15c55744b | |||
| 54f7bb1b84 | |||
| 4766ef4729 | |||
| 7fbb4095fc | |||
| 627b9b7a54 | |||
| 4ef6f96b14 | |||
| b956f6ccdf | |||
| d70f11c904 | |||
| 4c13cf6ef5 | |||
| 149b52f831 | |||
| 24f6433b2b | |||
| 699aa7cfc8 | |||
| 35ce7853fa | |||
| 001f29ca5f | |||
| c114c4187d | |||
| ef996c8394 | |||
| a49aa2d31f | |||
| 726ac71214 | |||
| 948dc2236b | |||
| 4fb27d791b | |||
| 24208893d6 | |||
| c5317abada | |||
| 92f8e9a61a | |||
| 772e3ed5e3 | |||
| a1d508ed69 | |||
| 010719eee9 | |||
| 0cd43f0aea | |||
| 9ff49230fc | |||
| b08516f557 | |||
| 3bd8724d45 | |||
| ea44785ef0 | |||
| d3a82d1653 | |||
| 984adcfa62 | |||
| 6f934da428 | |||
| fd8bd01f33 | |||
| 4fdfbfe53e | |||
| 12b8958a72 | |||
| 6c6fab3ea3 | |||
| 85d3f4099d | |||
| f33f023b90 | |||
| a96ef3d204 | |||
| addb0fe042 | |||
| 4293013d4f | |||
| ec0c6eae26 | |||
| ea90327507 | |||
| c312d4cc6d | |||
| 5a84e9f6cc | |||
| 349cdf5fcd | |||
| 5211842d47 | |||
| 93d44c055b | |||
| 2ef851086d | |||
| 969d32eaca | |||
| 74063494a1 | |||
| 46262c80c1 | |||
| c6ddf7c485 | |||
| 06a6875cdb | |||
| df09694ed6 | |||
| 1e53b66abb | |||
| 332b657add | |||
| 873d56e35f | |||
| dce418d0b9 | |||
| 3d7d4767bf | |||
| f4b1c449b1 | |||
| 24f477a28e | |||
| c7b213bf17 | |||
| c3605cc86b | |||
| f5c4ffa92f | |||
| 6971b69b79 | |||
| 337337a554 | |||
| 3b56346c23 | |||
| 9e473ae8a8 | |||
| 6e312cc5fd | |||
| 2fb7bae5f6 | |||
| c95105f018 | |||
| 7b2004e05c | |||
| e6ade9ce77 | |||
| 6843b96fe0 | |||
| 763c04398e | |||
| 02255116f4 | |||
| b3a7f47cf7 | |||
| 4e7f3e7235 | |||
| 0c1b512a9a | |||
| d086b7ea3c | |||
| 97a60ead91 | |||
| beae6bb4b1 | |||
| 886e99bfa9 | |||
| 43ec19e94f | |||
| 69d7f053be | |||
| 332ac9c0d8 | |||
| 46db7ac026 | |||
| 2e04c5a893 | |||
| c78604df06 | |||
| 1b8dc2c4ab | |||
| e3d279cb83 | |||
| 890ddd9b3c | |||
| 4ad7518328 | |||
| 2ca26cafb2 | |||
| 6a3bb19e7d | |||
| 8942c78fb4 | |||
| fe70fd216b | |||
| 6b115799c3 | |||
| 1524da2d6a | |||
| a2f2b2dd71 | |||
| cdf2c36915 | |||
| 003f12f008 | |||
| aaa3dc2fb9 | |||
| 583755c189 | |||
| bd296f9425 | |||
| 95aba376b1 | |||
| 59d53bc1b2 | |||
| a65f9afc69 | |||
| 94520db699 | |||
| bae35dd8a5 | |||
| 02a52fdb38 | |||
| 692670a081 | |||
| 8dacb9ddba | |||
| e200f4a59a | |||
| f0bf58d336 | |||
| 462ae91a9e | |||
| 993882233b | |||
| d08df8767d | |||
| ec42739764 | |||
| 81d70c87f1 | |||
| d9019ffdc9 | |||
| 2b49fd92b7 | |||
| 797c6b0b8a | |||
| b582c82c6f | |||
|
|
71a006cd7b | ||
|
|
3186fab596 | ||
|
|
1a4ce959ad | ||
|
|
4b0fbdde98 | ||
|
|
8bab8d44cc | ||
|
|
c3ae316570 | ||
|
|
51f09bf53c | ||
|
|
e2379658c2 | ||
|
|
9facd24a00 | ||
| f51cdba51a | |||
| d9e8fee64b | |||
| e5ebd26182 | |||
| 391773ac90 | |||
| 32a0efbf1b | |||
| 8d505cec0e | |||
| 1f9512a5a7 | |||
| 37bc1bba22 | |||
| 8a4dc1a320 | |||
| ded1e1f5c4 | |||
| 634f869a84 | |||
| 6c1da03e91 | |||
| 5bf3ef3222 | |||
| 5502e35dc5 | |||
| fdffeacf50 | |||
| 54a853a5c6 | |||
| 27a7d226eb | |||
| a5fdeabd09 | |||
|
|
94362bf8eb | ||
| 6b30580f36 | |||
| bc73b85909 | |||
| d9b0ec410c | |||
| 5029b8049b | |||
| b406a8dc04 | |||
| e927fa8ea0 | |||
| 98bb6be549 | |||
| 68114eea66 | |||
| df145b2957 | |||
| 2364ff59d2 | |||
| 4d8b9d9f87 | |||
| 468ca475ed | |||
|
|
33afe1eddf | ||
|
|
4b34ab8161 | ||
|
|
b4342b355f | ||
|
|
26890dfabb | ||
|
|
45dfaf5905 | ||
|
|
34dba6689c | ||
|
|
e4680b0fe8 | ||
|
|
d2a4770967 | ||
|
|
72551e5f9d | ||
| 2f893a6d9e | |||
| c96a5350a7 | |||
| cfe97ecb1e | |||
| 3a057ee860 | |||
| 3ffc345c2c | |||
| cf3d049367 | |||
| 2a162f0efe | |||
| dcc5708d17 | |||
| 809ece6a68 | |||
| 2e14c9d6fe | |||
| 543607069e | |||
| 13469b14fb | |||
| 603b9e0032 | |||
| 987b4797bb | |||
| 8f78dbf68c | |||
| b3f33cfa30 | |||
| aa8dc05311 | |||
| 17168bceae | |||
| a75ae1de9a | |||
| f8d10c90b8 | |||
| 6192220ec1 | |||
| 5ae0e19e31 | |||
| 2383c6a6be | |||
| ffba1563a7 | |||
| 6a50dc280f | |||
| 641e4aba0d | |||
| 75cc6737bd | |||
| aa60a22d57 | |||
| 85b2049a61 | |||
| 0fcacc3f51 | |||
| 31b4e6b5f3 | |||
| ced369cdbc | |||
| cab204281b | |||
| 6337d975ea | |||
| dc4a5921c6 | |||
| 5d81027b34 | |||
| b3f0548c10 | |||
| ab9cbfc897 | |||
| aad4ea84a1 | |||
| 6a4c37603d | |||
| d83646a7ef | |||
| c244917737 | |||
| 7c21e500d6 | |||
| d10f80d41d | |||
| 8cadd82a2b | |||
| 39bd003c48 | |||
| 6fbf1a17b8 | |||
| d97feffebb | |||
| ab999c1e16 | |||
| e1b2882086 | |||
| 79b7abd67b | |||
| 96be117851 | |||
| 1d37bf5420 | |||
| ddef0f7f05 | |||
| a669d57e4a | |||
| 311e491683 | |||
| 870c11381c | |||
| 5a768d938a | |||
| 839fabd056 | |||
| 1951336307 | |||
| 650c65c888 | |||
| 5bb10ba1e6 | |||
| 118e004294 | |||
| a4f283e4e6 | |||
| 39efd68296 | |||
| d1c5ad8d33 | |||
| 64e7c5241e | |||
| b0e18cc724 | |||
| 3c5b0d5d99 | |||
| b2f96b216d | |||
| 79ab9f3e7f | |||
| 643740ca21 | |||
| 83553b5601 | |||
| e98ab39dfe | |||
| d608bdb5a8 | |||
| 3c54c46898 | |||
| 101baa68f6 | |||
| 3269bbb981 | |||
| 397605d5e5 | |||
| 2571233c4a | |||
| 5c995a5b4d | |||
| 0bb41ae354 | |||
|
|
318948c2fb | ||
| 2de22869c0 | |||
| d0e4f8f86a | |||
| 918f133867 | |||
| 89ac5738e5 | |||
| 4aa2c441c6 | |||
| 691d9e5dd6 | |||
| c8291da699 | |||
| 44231b1c2e | |||
| 07f4c1258c | |||
| 77c6610e70 | |||
| 2ebe2c5613 | |||
| 0f82932b35 | |||
| 759bc1ff68 | |||
| e909776c5d | |||
| 409cb09143 | |||
| a70e637bca | |||
| 067f9f9baf | |||
| a6536364ec | |||
| 896a51df3d | |||
| 3e335eb9cf | |||
| 046acd3ca6 | |||
| aff21f195b | |||
| 926c26b1ad | |||
| f072d37362 | |||
| 83991b13ca | |||
| 4d11d3e554 | |||
| a6c552236f | |||
| 4b42249609 | |||
| edf914f100 | |||
| 1dc3a01257 | |||
| f2c2a8511c | |||
| e3e36d6e40 | |||
| caa1152268 | |||
| d95fec4651 | |||
| ff37ad918a | |||
| 57702fc672 | |||
| 9cee09c140 | |||
| f8384bda9e | |||
| 46f74ec7ad | |||
| 161c0163d3 | |||
| 09f681b8dd | |||
| f59206e589 | |||
| 51bbde5dd7 | |||
| 406eef240d | |||
| d3442cdc33 | |||
| 6c7581d558 | |||
| e535f1838b | |||
| 99def9a0e5 | |||
| 5ffc0198a3 | |||
| 725ba375d2 | |||
| a89b25f54c | |||
| 1af3103cde | |||
| 3d26eebcdf | |||
| 551cd62808 | |||
| d305398326 | |||
| ec8abf39aa | |||
| 2463b5a67f | |||
| 42f0caa6fd | |||
| 27e0f4c9dd | |||
| 9bce34026c | |||
| 8bc5b5a49b | |||
| f239ac984f | |||
| 9d888a1162 | |||
| fb27fbf3b1 | |||
| 9a4681b2c0 | |||
| 1ff12075f6 | |||
| 95ae991af4 | |||
| eac16cfcd9 | |||
| 0f8b19a9b1 | |||
| 1b4b5d433f | |||
| e2d3e389f3 | |||
| 45ae1bb1c0 | |||
| c2b55081a6 | |||
| 3113fc09ff | |||
| c1479a32a7 | |||
| 03e8ed4822 | |||
| 16ba7ee47a | |||
| 5be5ffd42f | |||
| f258b1d457 | |||
| f55374f827 | |||
| 5034785582 | |||
| d6a6e13678 | |||
| 5649ba2a76 | |||
| 9720b77898 | |||
| c126634e16 | |||
| a5102d9b25 | |||
| d3b4e3ef5e | |||
| 4392810ec7 | |||
| 71473b0f6c | |||
| 539a87e93a | |||
| e97c5418b9 | |||
| 6506bd192d | |||
| b6c7a9dc43 | |||
| 0a784e5534 | |||
| 9ac99d2e0c | |||
| a973cd746b | |||
| 39b41a4c42 | |||
| 9da97554ce | |||
| 145b807ebf | |||
| 20a8a19e12 | |||
| 2b4b40c0d9 | |||
| 88720b48c4 | |||
| d9ed46f4b9 | |||
| 5b89ed5685 | |||
| d1c3bba3e0 | |||
| 02acdf835f | |||
| 03a4c553f8 | |||
| 7c28bd4867 | |||
| db88c7ab1c | |||
| 9946108313 | |||
| dc9332809b | |||
| 69470e8e4a | |||
| 0ad57ab69c | |||
| 1eb9c15e90 | |||
| cb16a8a9a5 | |||
| d7071084b7 | |||
| a4f483332c | |||
| 4cd0de5174 | |||
| 1932180cc8 | |||
| badaaa0d1b | |||
| 5423f920b7 | |||
| f9e5171eb8 | |||
| dcda5e5bfe | |||
| f485c07f54 | |||
| 11a06fa94d | |||
| a6e7f1253c | |||
| 7cf07a5627 | |||
| 7c1dbaf206 | |||
| b2dae93ac1 | |||
| c9b780659f | |||
| 20c97843c3 | |||
| 1c3985ce19 | |||
| 86ef9c6f60 | |||
| 8db37c377a | |||
| 914b1b0d49 | |||
| 45d1d3b910 | |||
| 7bb1f3f702 | |||
| 890a4b77dd | |||
| e1ebb78331 | |||
| d50e583c30 | |||
| dcb43a1134 | |||
| 00e36b429a | |||
| 5859118936 | |||
| 6482e8d3e0 | |||
| c584c28f1a | |||
| f6647230f7 | |||
| cba839ad1e | |||
| 1bb1120015 | |||
| f6769fa1db | |||
| 6d9899acbb | |||
| 497ffff216 | |||
| 77af59b7a8 | |||
| cd3124f91d | |||
| 89f07f3bcd | |||
| f97b989455 | |||
| f02ba3cbbd | |||
| ca45a14bae | |||
| ac778f836f | |||
| 9a409689ee | |||
| 39062e1773 | |||
| 5a7231eba6 | |||
| d60bc1d5d5 | |||
| c8f39c15e0 | |||
| 099a8c768c | |||
| 2b20a85bc5 | |||
| 24f9fb6904 | |||
| 6d80b04a55 | |||
| 3cfece2a33 | |||
| 85538ae672 | |||
| 600961f33d | |||
| 93a92cdcb4 | |||
| 3423178250 | |||
| 19f5096470 | |||
| 242c088730 | |||
| 75cf8355de | |||
| 45dc68427a | |||
| 558d88593a | |||
| 38e0b6d80e | |||
| 75cde56761 | |||
| c9a364a8ba | |||
| 077ff0e6a4 | |||
|
|
7fee9c597a | ||
|
|
8a074f16e7 | ||
|
|
aeb418fb9f | ||
| bc07619452 | |||
| fb78bea6f2 | |||
|
|
4ffe5110dd | ||
| beb288951c | |||
| 255a47987a | |||
| aeae296c48 | |||
| 67457a23a1 | |||
| d63b943039 | |||
| b2f0ea48e5 | |||
| c54dcccf49 | |||
| 3fdcaa5832 | |||
| 7582ba4208 | |||
| 8282c222de | |||
| 70bb29827e | |||
| 6a7676f95c | |||
|
|
cb6edb850a | ||
|
|
5da74dac3a | ||
|
|
fb7e46054e | ||
|
|
c61e32eec5 | ||
|
|
bb3231effe | ||
|
|
2bdfc2eb51 | ||
| c4d3a9f7a1 | |||
| d525895ae7 | |||
| 19d3bade30 | |||
| a0dc14b1b7 | |||
| 4011a65683 | |||
| 6a735ad501 | |||
| 2da570f42c | |||
| 4dc4e19c27 | |||
| 88728f01e6 | |||
| 12b1bc4aca | |||
| 0c4a48a7d3 | |||
| 68becb43bc | |||
| 04938d7cd9 | |||
| 73b0453ad4 | |||
| dfb5c2bce5 | |||
| 1c6fb4ef83 | |||
| 2c42986a8b | |||
| 2624931a7f | |||
| 5dd5dd1086 | |||
| 7d57e5844f | |||
| c6470d3bae | |||
| 0ccd1db649 | |||
| ed2684c45f | |||
| a5b5b3bf8c | |||
| c53f7b86db | |||
| 2e34899757 | |||
| 919bcd27e8 | |||
| 2ec2653bfb | |||
| d756602d48 | |||
| 8d8c64ec27 | |||
| c9a42ee232 | |||
| 870e88360e | |||
| 9dbd539855 | |||
| 3b66e4ae16 | |||
|
|
0509a7dda5 | ||
|
|
9f50a6e14a | ||
|
|
62a2047374 | ||
|
|
c117e10f48 | ||
|
|
226a236bf2 | ||
|
|
9e3e08dd24 | ||
|
|
a14c67f189 | ||
|
|
cee5bf13af | ||
|
|
3e05c4e5f6 | ||
| f844f2586a | |||
|
|
8ed3bd8c77 | ||
|
|
5d8697e361 | ||
| f8b8b262cb | |||
|
|
9a4808f0e5 | ||
|
|
bc5a18bc1e | ||
|
|
4c9f71147c | ||
|
|
2811ecf268 | ||
| e40dd8120e | |||
| ba7cca3c60 | |||
| 8664c5f17a | |||
| 352a4fab0b | |||
| 19c67a0d91 | |||
| 4696d7ce82 | |||
| 1522c58904 | |||
| 05c33249d9 | |||
| 410c770738 | |||
| 3eb7ed01ee | |||
| 466e7f1e54 | |||
| 86f3e7a21c | |||
| b43ace8b2d | |||
| 43a4909ddf | |||
| 95136cd5df | |||
| b675159510 | |||
| 478fb009a5 | |||
| 1b5b2b9d1a | |||
| fdbc55f35c | |||
| ebd95166ae | |||
| 2948cc151b | |||
|
|
85998bd82e | ||
|
|
3025be52d5 | ||
| 65c45c7571 | |||
| c852af3168 | |||
| b7b29bb504 | |||
| f1959f123b | |||
| 5cb713a009 | |||
| e6bfcf465f | |||
|
|
dcdc69ebfe | ||
|
|
33660cfdcf | ||
|
|
f617467082 | ||
|
|
6fd0e5c800 | ||
|
|
1a5b04d688 | ||
|
|
7808d81bb4 | ||
| 37e8fc4991 | |||
| 11ae96df78 | |||
| b15cad759c | |||
| b7e0010d36 | |||
| f7cdf367ae | |||
| 41eaac62c9 | |||
| 837883756f | |||
| f0b1a88005 | |||
| e322cbffb8 | |||
| ad5895a1ea | |||
| 779b627b64 | |||
| 51eb76acf7 | |||
| 92f084fd59 | |||
| 7f8d52ac3f | |||
| 75107c5c41 | |||
| ce703005e1 | |||
| 594fd24adb | |||
|
|
14af8f3fa9 | ||
|
|
485bbafe71 | ||
|
|
4178152e29 | ||
|
|
1bc2cfb507 | ||
|
|
db71364e80 | ||
|
|
c1645b2d4b | ||
|
|
dfa2fc2406 | ||
|
|
b6d3b69cda | ||
|
|
2441c64598 | ||
|
|
a8a219d7ef | ||
|
|
b0792113ae | ||
|
|
d590acfff9 | ||
|
|
47193e56ff | ||
|
|
7c936da7ed | ||
| 6a31082dbc | |||
| c8506e70d5 | |||
| be320c98bd | |||
| 11ce54172f | |||
| cc1b74ffb6 | |||
| 6a011a7c1d | |||
| f0362df47d | |||
| 43b694cb53 | |||
| c23e0c925a | |||
| 7a25cf40aa | |||
| 66c9b859dc | |||
| 8415069c0a | |||
| 2f1caa7b03 | |||
|
|
66106f20f6 | ||
| b9ad54d459 | |||
|
|
5bdb08d673 | ||
|
|
fbe1851e65 | ||
| f1adc73769 | |||
| d12b431894 | |||
| 74884f6616 | |||
|
|
7131d667ab | ||
|
|
d2c7d490f6 | ||
|
|
84833612ee | ||
|
|
812903bf51 | ||
| 21b9594de5 | |||
| 6f397895d7 | |||
| b59e70031d | |||
| 6ae35e1bd7 | |||
| dc09c2dc6b | |||
| 787d7aa0f0 | |||
| ab2a5462d4 | |||
| bb7f3a7b25 | |||
| 516a7b3635 | |||
| 50209a1506 | |||
| 6c7e80eb3e | |||
| 2c6322db60 | |||
| 9d7c28b1c1 | |||
| 3163514227 | |||
| 2864a946f2 | |||
| eee482197c | |||
| 474c8971a7 | |||
| 8c27c74b38 | |||
| 68df43f3a8 | |||
| 8856485265 | |||
| afaac1781c | |||
| dc0d1a8e63 | |||
| 5ffc447265 | |||
| f83af9c8cc | |||
| 10aa6f837f | |||
| 4ef7ab78e2 | |||
| 1548e60361 | |||
|
|
655a32fd97 | ||
|
|
c36bcfd01e | ||
|
|
13f002b25b | ||
|
|
71b93cae3b | ||
|
|
4202544a51 | ||
|
|
20d877add1 | ||
|
|
731d4dfd93 | ||
|
|
e0cf51893a | ||
|
|
490567356f | ||
|
|
05cdc7d8ae | ||
|
|
390a349de6 | ||
|
|
2fd4631331 |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.gitea
|
||||
.codex
|
||||
.env
|
||||
.env.*
|
||||
**/.dart_tool
|
||||
**/.packages
|
||||
**/build
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.next
|
||||
**/.cache
|
||||
**/coverage
|
||||
**/tmp
|
||||
**/logs
|
||||
**/*.log
|
||||
**/*.swp
|
||||
**/.DS_Store
|
||||
23
.env.sample
23
.env.sample
@@ -28,6 +28,8 @@ DB_NAME=baron_sso
|
||||
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||
JWT_SECRET=super-secret-key-must-be-32-bytes!
|
||||
# Optional backend slog override: debug, info, warn, error
|
||||
BACKEND_LOG_LEVEL=
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||
|
||||
@@ -59,7 +61,8 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple
|
||||
USERFRONT_URL=https://sso.hmac.kr
|
||||
|
||||
# Services proxied via Nginx
|
||||
BACKEND_URL=${USERFRONT_URL}/api
|
||||
BACKEND_PUBLIC_URL=${USERFRONT_URL}
|
||||
BACKEND_URL=${USERFRONT_URL}
|
||||
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
|
||||
|
||||
# ory-stack 변수들
|
||||
@@ -90,6 +93,8 @@ HYDRA_VERSION=v25.4.0-distroless
|
||||
KETO_VERSION=v25.4.0-distroless
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
|
||||
# Kratos Selfservice UI upstreams (override for deployments)
|
||||
ORY_SDK_URL=http://kratos:4433
|
||||
@@ -106,6 +111,10 @@ HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
|
||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||
|
||||
# Oathkeeper JWKS (내부 통신용)
|
||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||
|
||||
@@ -122,3 +131,15 @@ OATHKEEPER_HEALTH_ENABLED=true
|
||||
COOKIE_SECRET=localcookie123
|
||||
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
||||
CSRF_COOKIE_SECRET=localcsrf123
|
||||
|
||||
# AdminFront OIDC 설정
|
||||
ADMINFRONT_URL=http://localhost:5173
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||
|
||||
# DevFront OIDC 설정
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
DEVFRONT_URL=http://localhost:5174
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
|
||||
VITE_ORGCHART_URL=
|
||||
1886
.env.test2
Normal file
1886
.env.test2
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,109 +1,811 @@
|
||||
name: Code Check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_lint:
|
||||
description: "Run linters for Go and Flutter"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_backend_tests:
|
||||
description: "Run backend Go tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_tests:
|
||||
description: "Run userfront Flutter tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_lint:
|
||||
description: "Run lint/format checks for Go, Flutter, adminfront, devfront"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_backend_tests:
|
||||
description: "Run backend Go tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_tests:
|
||||
description: "Run userfront Flutter tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_e2e_tests:
|
||||
description: "Run userfront WASM Playwright E2E tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_adminfront_tests:
|
||||
description: "Run adminfront Playwright tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_devfront_tests:
|
||||
description: "Run devfront Playwright tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
if: ${{ inputs.run_lint == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
lint:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
adminfront/package-lock.json
|
||||
devfront/package-lock.json
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: i18n resource check
|
||||
run: |
|
||||
mkdir -p reports
|
||||
node tools/i18n-scanner/index.js
|
||||
node tools/i18n-scanner/report.js
|
||||
cat reports/i18n-report.txt
|
||||
|
||||
- name: Lint Go backend
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.59
|
||||
working-directory: backend
|
||||
args: --enable-only=gofmt,gofumpt
|
||||
- name: i18n value quality check
|
||||
run: |
|
||||
mkdir -p reports
|
||||
node tools/i18n-scanner/value-check.js
|
||||
cat reports/i18n-value-report.txt
|
||||
|
||||
- name: Analyze Flutter userfront
|
||||
run: |
|
||||
cd userfront
|
||||
flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
backend-tests:
|
||||
needs: lint
|
||||
if: ${{ inputs.run_backend_tests == true }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >
|
||||
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.6
|
||||
options: >
|
||||
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
env:
|
||||
REDIS_ADDR: redis:6379
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT_NATIVE: 9000
|
||||
- name: Install adminfront dependencies
|
||||
run: |
|
||||
cd adminfront
|
||||
npm ci
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Biome check adminfront (lint + format)
|
||||
run: |
|
||||
cd adminfront
|
||||
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Install devfront dependencies
|
||||
run: |
|
||||
cd devfront
|
||||
npm ci
|
||||
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
cd backend
|
||||
go test -v ./...
|
||||
- name: Biome check devfront (lint + format)
|
||||
run: |
|
||||
cd devfront
|
||||
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
userfront-tests:
|
||||
needs: lint
|
||||
if: ${{ inputs.run_userfront_tests == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Lint Go backend
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}/backend:/app" \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v2.10.1 \
|
||||
golangci-lint fmt -E gofmt -E gofumpt -d
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Run userfront tests
|
||||
run: |
|
||||
cd userfront
|
||||
if [ -d test ]; then
|
||||
flutter test
|
||||
else
|
||||
echo "No userfront tests: skipping (test/ directory not found)."
|
||||
fi
|
||||
- name: Install Userfront dependencies
|
||||
run: |
|
||||
cd userfront
|
||||
flutter pub get
|
||||
|
||||
- name: Format Flutter userfront
|
||||
run: |
|
||||
cd userfront
|
||||
dart format --output=none --set-exit-if-changed lib test
|
||||
|
||||
- name: Analyze Flutter userfront
|
||||
run: |
|
||||
cd userfront
|
||||
flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||
|
||||
backend-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >
|
||||
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.6
|
||||
options: >
|
||||
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
env:
|
||||
REDIS_ADDR: redis:6379
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT_NATIVE: 9000
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd backend
|
||||
go test -v ./... 2>&1 | tee ../reports/backend-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Backend Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`backend-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`go test -v ./...\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/backend-test.log
|
||||
echo '```'
|
||||
} > reports/backend-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
- name: Publish backend failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/backend-test-failure-report.md ]; then
|
||||
cat reports/backend-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload backend failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: backend-test-failure-report
|
||||
path: |
|
||||
reports/backend-test-failure-report.md
|
||||
reports/backend-test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
userfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Run userfront tests
|
||||
run: |
|
||||
cd userfront
|
||||
if [ -d test ]; then
|
||||
mkdir -p ../reports
|
||||
set +e
|
||||
flutter test 2>&1 | tee ../reports/userfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`flutter test\`"
|
||||
echo
|
||||
if [ -f ../reports/userfront-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 ../reports/userfront-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > ../reports/userfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No userfront tests: skipping (test/ directory not found)."
|
||||
fi
|
||||
|
||||
- name: Ensure userfront failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/userfront-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Userfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/userfront-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/userfront-test-failure-report.md
|
||||
|
||||
- name: Publish userfront failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/userfront-test-failure-report.md ]; then
|
||||
cat reports/userfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload userfront failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: userfront-test-failure-report
|
||||
path: |
|
||||
reports/userfront-test-failure-report.md
|
||||
reports/userfront-test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
userfront-e2e-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: userfront-e2e/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd userfront-e2e
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Install userfront-e2e dependencies
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npm ci 2>&1 | tee ../reports/userfront-e2e-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Dependency install failed\`"
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront-e2e && npm ci\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-install.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build userfront WASM
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront
|
||||
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
|
||||
build_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$build_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`WASM build failed\`"
|
||||
echo "- Exit Code: \`$build_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront && flutter build web --wasm --release\`"
|
||||
echo
|
||||
echo "## Build Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-build.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for userfront-e2e tests
|
||||
run: |
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npx playwright install --with-deps chromium 2>&1 | tee ../reports/userfront-e2e-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Browser provisioning failed\`"
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront-e2e && npx playwright install --with-deps chromium\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-provision.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run userfront-e2e tests
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npm test 2>&1 | tee ../reports/userfront-e2e-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd userfront-e2e\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`cd ../userfront && flutter build web --wasm --release\`"
|
||||
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`"
|
||||
echo "5. \`npm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-test.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
- name: Ensure userfront-e2e failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/userfront-e2e-install.log ]; then
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-install.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-build.log ]; then
|
||||
echo "## Build Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-build.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-provision.log ]; then
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-provision.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
|
||||
- name: Publish userfront-e2e failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
|
||||
cat reports/userfront-e2e-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload userfront-e2e failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: userfront-e2e-test-failure-report
|
||||
path: |
|
||||
reports/userfront-e2e-test-failure-report.md
|
||||
reports/userfront-e2e-install.log
|
||||
reports/userfront-e2e-build.log
|
||||
reports/userfront-e2e-provision.log
|
||||
reports/userfront-e2e-test.log
|
||||
userfront-e2e/playwright-report
|
||||
userfront-e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
adminfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: adminfront/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd adminfront
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Run adminfront tests
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
run: |
|
||||
scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
- name: Ensure adminfront failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/adminfront-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Adminfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`adminfront-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/adminfront-install.log ]; then
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-install.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/adminfront-provision.log ]; then
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-provision.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/adminfront-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/adminfront-test-failure-report.md
|
||||
|
||||
- name: Publish adminfront failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/adminfront-test-failure-report.md ]; then
|
||||
cat reports/adminfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload adminfront failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: adminfront-test-failure-report
|
||||
path: |
|
||||
reports/adminfront-test-failure-report.md
|
||||
reports/adminfront-install.log
|
||||
reports/adminfront-provision.log
|
||||
reports/adminfront-test.log
|
||||
adminfront/playwright-report
|
||||
adminfront/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
devfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
cache-dependency-path: devfront/package-lock.json
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
cd devfront
|
||||
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install devfront dependencies
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd devfront
|
||||
npm ci 2>&1 | tee ../reports/devfront-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Devfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`devfront-tests\`"
|
||||
echo "- Reason: \`Dependency install failed\`"
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd devfront && npm ci\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-install.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for devfront tests
|
||||
run: |
|
||||
set +e
|
||||
cd devfront
|
||||
npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Devfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`devfront-tests\`"
|
||||
echo "- Reason: \`Browser provisioning failed\`"
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd devfront && npx playwright install --with-deps\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-provision.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run devfront tests
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd devfront
|
||||
npm test 2>&1 | tee ../reports/devfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Devfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`devfront-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd devfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`npx playwright install --with-deps\`"
|
||||
echo "4. \`npm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-test.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
- name: Ensure devfront failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/devfront-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Devfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`devfront-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/devfront-install.log ]; then
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-install.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/devfront-provision.log ]; then
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-provision.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/devfront-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/devfront-test-failure-report.md
|
||||
|
||||
- name: Publish devfront failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/devfront-test-failure-report.md ]; then
|
||||
cat reports/devfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload devfront failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: devfront-test-failure-report
|
||||
path: |
|
||||
reports/devfront-test-failure-report.md
|
||||
reports/devfront-install.log
|
||||
reports/devfront-provision.log
|
||||
reports/devfront-test.log
|
||||
devfront/playwright-report
|
||||
devfront/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
194
.gitea/workflows/staging_code_pull.yml
Normal file
194
.gitea/workflows/staging_code_pull.yml
Normal file
@@ -0,0 +1,194 @@
|
||||
name: Release Baron SSO to Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: "Branch to deploy"
|
||||
required: true
|
||||
default: "dev"
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Staging by git pull
|
||||
env:
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
|
||||
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
|
||||
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
|
||||
echo "DEBUG: TARGET_BRANCH='${TARGET_BRANCH}'"
|
||||
|
||||
# Sanity check
|
||||
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ] || [ -z "${TARGET_BRANCH}" ]; then
|
||||
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH/TARGET_BRANCH)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||
|
||||
# .env 파일 생성
|
||||
cat <<'EOF' > .env
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# DB & Clickhouse
|
||||
DB_PORT=${{ vars.DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
|
||||
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
|
||||
REDIS_ADDR=${{ vars.REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.HYDRA_DB }}
|
||||
KETO_DB=${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||
JWKS_URL=${{ vars.JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
|
||||
# Frontend OIDC configs for Staging
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
# 코드 업데이트 (Git)
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin ssh://git@172.16.10.175:222/baron/baron-sso.git
|
||||
else
|
||||
git remote set-url origin ssh://git@172.16.10.175:222/baron/baron-sso.git
|
||||
fi
|
||||
git fetch --depth 1 origin '${TARGET_BRANCH}' && \
|
||||
git reset --hard FETCH_HEAD && \
|
||||
git clean -fd && \
|
||||
git checkout -B '${TARGET_BRANCH}' FETCH_HEAD"
|
||||
|
||||
# .env 파일 복사
|
||||
scp .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
|
||||
# 배포 실행
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "DEPLOY_PATH='${DEPLOY_PATH}' bash -s" <<'EOSSH'
|
||||
set -euo pipefail
|
||||
cd "${DEPLOY_PATH}"
|
||||
set -a; . ./.env; set +a;
|
||||
|
||||
# 네트워크 생성
|
||||
for net in baron_net public_net ory-net hydranet kratosnet; do
|
||||
docker network inspect "${net}" >/dev/null 2>&1 || docker network create "${net}"
|
||||
done
|
||||
|
||||
|
||||
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
|
||||
chmod -R 777 docker/ory || true
|
||||
|
||||
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
|
||||
|
||||
docker compose -f staging_pull_compose.yaml pull
|
||||
|
||||
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
||||
docker compose -f staging_pull_compose.yaml build --pull
|
||||
|
||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
||||
docker compose -f staging_pull_compose.yaml up -d init-rp
|
||||
|
||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||
sleep 10
|
||||
|
||||
echo "===== INIT-RP LOGS ====="
|
||||
docker compose -f staging_pull_compose.yaml logs init-rp || true
|
||||
echo "========================"
|
||||
|
||||
kratos_migrate_cid="$(docker compose -f staging_pull_compose.yaml ps -q kratos-migrate || true)"
|
||||
if [ -n "${kratos_migrate_cid}" ]; then
|
||||
if [ "$(docker inspect -f '{{.State.ExitCode}}' "${kratos_migrate_cid}")" -ne 0 ]; then
|
||||
echo 'Kratos Migrate Failed. Logs:'
|
||||
docker logs "${kratos_migrate_cid}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARN: kratos-migrate container not found; skipping exit-code check."
|
||||
fi
|
||||
EOSSH
|
||||
@@ -54,7 +54,9 @@ jobs:
|
||||
|
||||
# .env 파일 생성
|
||||
cat <<'EOF' > .env
|
||||
APP_ENV=${{ vars.APP_ENV }}
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
@@ -96,6 +98,7 @@ jobs:
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
@@ -126,6 +129,10 @@ jobs:
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
@@ -187,4 +194,4 @@ jobs:
|
||||
echo 'Kratos Migrate Failed. Logs:'; \
|
||||
docker logs baron-sso-staging-kratos-migrate-1; \
|
||||
exit 1; \
|
||||
fi"
|
||||
fi"
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -6,14 +6,20 @@
|
||||
.vscode/
|
||||
.codex
|
||||
.codex/
|
||||
.serena/
|
||||
.generated/
|
||||
*.swp
|
||||
*.log
|
||||
*.out
|
||||
*.exe
|
||||
.npm-cache/
|
||||
reports
|
||||
reports/*
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
clickhouse_data/
|
||||
docker/ory/oathkeeper/logs/
|
||||
|
||||
# Backend (Go)
|
||||
backend/main
|
||||
@@ -30,3 +36,9 @@ userfront/.dart_tool/
|
||||
userfront/.packages
|
||||
userfront/.pub/
|
||||
userfront/.env
|
||||
|
||||
# Frontend test artifacts
|
||||
adminfront/test-results/
|
||||
devfront/test-results/
|
||||
adminfront/playwright-report/
|
||||
devfront/playwright-report/
|
||||
|
||||
221
Makefile
221
Makefile
@@ -10,29 +10,50 @@ endif
|
||||
COMPOSE_INFRA := compose.infra.yaml
|
||||
COMPOSE_ORY := compose.ory.yaml
|
||||
COMPOSE_APP := docker-compose.yaml
|
||||
AUTH_CONFIG_ENV := .generated/auth-config.env
|
||||
|
||||
COMPOSE_CLI_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
COMPOSE_CLI_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@echo "Building auth config..."
|
||||
@mkdir -p .generated
|
||||
@bash scripts/auth_config.sh build
|
||||
|
||||
validate-auth-config: build-auth-config
|
||||
@echo "Validating auth config..."
|
||||
@bash scripts/auth_config.sh validate
|
||||
|
||||
verify-auth-config: validate-auth-config
|
||||
@echo "Verifying auth config wiring..."
|
||||
@bash scripts/auth_config.sh verify
|
||||
|
||||
# --- 기본 실행 ---
|
||||
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||
up-all:
|
||||
up-all: validate-auth-config
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra:
|
||||
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||
docker compose -f $(COMPOSE_INFRA) up -d
|
||||
|
||||
up-ory:
|
||||
up-ory: validate-auth-config
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose -f $(COMPOSE_ORY) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||
|
||||
up-app:
|
||||
up-app: validate-auth-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||
docker compose -f $(COMPOSE_APP) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
|
||||
|
||||
up-backend:
|
||||
up-backend: validate-auth-config
|
||||
@echo "Starting Backend only..."
|
||||
docker compose -f $(COMPOSE_APP) up -d backend
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
|
||||
|
||||
up-dev: up-infra up-ory
|
||||
@echo "Dev stack is up (infra + ory)."
|
||||
@@ -41,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down-all:
|
||||
down:
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
@@ -84,3 +105,185 @@ logs-ory:
|
||||
|
||||
logs-app:
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
|
||||
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
|
||||
|
||||
ifeq ($(CI),)
|
||||
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||
else
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
||||
endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
CODE_CHECK_TEST_JOBS ?= 1
|
||||
PLAYWRIGHT_WORKERS ?= 1
|
||||
FLUTTER_TEST_CONCURRENCY ?= 1
|
||||
|
||||
code-check: code-check-lint code-check-test-jobs
|
||||
@echo "code-check complete."
|
||||
|
||||
code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint
|
||||
|
||||
code-check-test-jobs:
|
||||
@echo "==> run CI-equivalent test jobs (parallel)"
|
||||
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
|
||||
code-check-backend-tests \
|
||||
code-check-userfront-tests \
|
||||
code-check-userfront-e2e-tests \
|
||||
code-check-adminfront-tests \
|
||||
code-check-devfront-tests
|
||||
|
||||
code-check-i18n:
|
||||
@echo "==> i18n resource check"
|
||||
@mkdir -p reports
|
||||
node tools/i18n-scanner/index.js
|
||||
node tools/i18n-scanner/report.js
|
||||
@cat reports/i18n-report.txt
|
||||
|
||||
code-check-i18n-values:
|
||||
@echo "==> i18n value quality check"
|
||||
@mkdir -p reports
|
||||
node tools/i18n-scanner/value-check.js
|
||||
@cat reports/i18n-value-report.txt
|
||||
|
||||
code-check-go-lint:
|
||||
@echo "==> go lint/format check"
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
|
||||
elif command -v docker >/dev/null 2>&1; then \
|
||||
docker run --rm \
|
||||
-v "$$(pwd)/backend:/app" \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v2.10.1 \
|
||||
golangci-lint fmt -E gofmt -E gofumpt -d; \
|
||||
else \
|
||||
echo "ERROR: golangci-lint not found and docker is unavailable."; \
|
||||
echo "Install golangci-lint v2.10.1 or Docker to match CI lint step."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
code-check-sync-userfront-locales:
|
||||
@echo "==> sync userfront locales"
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
code-check-userfront-install:
|
||||
@echo "==> install userfront dependencies"
|
||||
cd userfront && flutter pub get
|
||||
|
||||
code-check-userfront-lint:
|
||||
@echo "==> userfront format/analyze"
|
||||
cd userfront && dart format --output=none --set-exit-if-changed lib test
|
||||
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||
|
||||
code-check-front-lint:
|
||||
@echo "==> adminfront biome lint/format check"
|
||||
rm -rf adminfront/playwright-report adminfront/test-results
|
||||
cd adminfront && npm ci
|
||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@echo "==> devfront biome lint/format check"
|
||||
rm -rf devfront/playwright-report devfront/test-results
|
||||
cd devfront && npm ci
|
||||
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
code-check-backend-tests:
|
||||
@echo "==> backend tests"
|
||||
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
|
||||
|
||||
code-check-userfront-tests:
|
||||
@echo "==> userfront tests (isolated workspace)"
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
cp -R locales "$$tmp_dir/locales"; \
|
||||
if command -v rsync >/dev/null 2>&1; then \
|
||||
rsync -a --delete \
|
||||
--exclude '.dart_tool' \
|
||||
--exclude 'build' \
|
||||
--exclude '.pub-cache' \
|
||||
--exclude '.flutter-plugins' \
|
||||
--exclude '.flutter-plugins-dependencies' \
|
||||
userfront/ "$$tmp_dir/userfront/"; \
|
||||
else \
|
||||
cp -R userfront "$$tmp_dir/userfront"; \
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
fi; \
|
||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
|
||||
|
||||
code-check-adminfront-tests:
|
||||
@echo "==> adminfront tests"
|
||||
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
code-check-devfront-tests:
|
||||
@echo "==> devfront tests"
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npm ci) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-userfront-e2e-tests:
|
||||
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
|
||||
@mkdir -p reports/userfront-e2e
|
||||
@rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
cp -R locales "$$tmp_dir/locales"; \
|
||||
if command -v rsync >/dev/null 2>&1; then \
|
||||
rsync -a --delete \
|
||||
--exclude '.dart_tool' \
|
||||
--exclude 'build' \
|
||||
--exclude '.pub-cache' \
|
||||
--exclude '.flutter-plugins' \
|
||||
--exclude '.flutter-plugins-dependencies' \
|
||||
userfront/ "$$tmp_dir/userfront/"; \
|
||||
rsync -a --delete \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'playwright-report' \
|
||||
--exclude 'test-results' \
|
||||
userfront-e2e/ "$$tmp_dir/userfront-e2e/"; \
|
||||
else \
|
||||
cp -R userfront "$$tmp_dir/userfront"; \
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
cp -R userfront-e2e "$$tmp_dir/userfront-e2e"; \
|
||||
rm -rf "$$tmp_dir/userfront-e2e/node_modules" "$$tmp_dir/userfront-e2e/playwright-report" "$$tmp_dir/userfront-e2e/test-results"; \
|
||||
fi; \
|
||||
status=0; \
|
||||
(cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && npm ci) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
||||
echo "==> userfront-e2e using PORT=$$port"; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
||||
exit $$status
|
||||
|
||||
356
README.md
356
README.md
@@ -2,6 +2,27 @@
|
||||
|
||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
|
||||
```
|
||||
baron_sso/
|
||||
├── backend/ # Go Fiber 애플리케이션
|
||||
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||
│ └── Dockerfile
|
||||
├── userfront/ # Flutter 애플리케이션
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── adminfront/ # React 기반 관리
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||
├── .env.sample # 환경 설정 템플릿
|
||||
└── README.md # 본 파일
|
||||
```
|
||||
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||
@@ -13,6 +34,7 @@
|
||||
* AdminFront: 사용자 관리 등 Admin 기능
|
||||
* DevFront: RP 관리 등 개발자 기능
|
||||
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
### 0. Ory Stack
|
||||
@@ -55,7 +77,7 @@ flowchart
|
||||
- userfront가 바라보는 backend
|
||||
|
||||
### 2. UserFront(Flutter Web/App)
|
||||
- **Framework**: Flutter 3.32+
|
||||
- **Framework**: Flutter 3.38.0+
|
||||
- **Key Packages**: `flutter_riverpod`, `go_router`
|
||||
- **Features**:
|
||||
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
|
||||
@@ -81,6 +103,122 @@ flowchart
|
||||
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
|
||||
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
|
||||
|
||||
### 5. Headless Login ID/Password Flow
|
||||
- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다.
|
||||
- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login`
|
||||
- 관련 구현:
|
||||
- `backend/internal/handler/auth_handler.go`
|
||||
- `backend/internal/domain/hydra_models.go`
|
||||
- `backend/internal/handler/auth_handler_login_test.go`
|
||||
|
||||
#### 호출 순서
|
||||
1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다.
|
||||
2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다.
|
||||
3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다.
|
||||
4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login`에 `client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다.
|
||||
5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다.
|
||||
6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다.
|
||||
|
||||
#### 요청 바디
|
||||
```json
|
||||
{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": "<signed-jwt>",
|
||||
"login_challenge": "<hydra-login-challenge>",
|
||||
"loginId": "employee001",
|
||||
"password": "secret"
|
||||
}
|
||||
```
|
||||
|
||||
#### 성공 응답
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"provider": "ory",
|
||||
"redirectTo": "https://rp.example.com/callback?code=..."
|
||||
}
|
||||
```
|
||||
|
||||
#### RP / Hydra 선행 조건
|
||||
- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다.
|
||||
- client가 headless login 선행 조건을 만족해야 합니다.
|
||||
- `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"`
|
||||
- `headless_jwks_uri`가 존재해야 합니다.
|
||||
- inline `headless_jwks`는 더 이상 지원하지 않습니다.
|
||||
- `headless_login_enabled == true`가 필요합니다.
|
||||
- `metadata.status == "inactive"`인 client는 차단됩니다.
|
||||
|
||||
#### `client_assertion` 규칙
|
||||
- 구현상 `client_assertion`은 현재 필수입니다.
|
||||
- 허용 서명 알고리즘:
|
||||
- `RS256`, `RS384`, `RS512`
|
||||
- `PS256`, `PS384`, `PS512`
|
||||
- `ES256`, `ES384`, `ES512`
|
||||
- `EdDSA`
|
||||
- JWT claim의 `iss`와 `sub`는 모두 `client_id`와 같아야 합니다.
|
||||
- `exp`는 현재 시각 이후여야 합니다.
|
||||
- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다.
|
||||
- `aud`는 다음 둘 중 하나와 일치해야 합니다.
|
||||
- `https://<backend-origin>/api/v1/auth/headless/password/login`
|
||||
- `/api/v1/auth/headless/password/login`
|
||||
- 서명 검증용 public key는 `headless_jwks_uri`에서만 읽습니다.
|
||||
|
||||
#### 내부 JWKS 캐시 정책
|
||||
- Baron Backend는 `headless_jwks_uri`를 직접 외부 스펙으로 저장하고, 실제 JWKS 문서는 내부 캐시에 저장해 사용합니다.
|
||||
- 등록/수정 이후에는 내부 캐시 동기화를 시도하고, 성공/실패 상태를 DevFront에서 확인할 수 있습니다.
|
||||
- 로그인 시 재조회는 다음 조건으로 제한합니다.
|
||||
- 캐시에 `kid`가 없을 때
|
||||
- `kid`는 있지만 서명 검증이 실패할 때
|
||||
- 캐시 TTL이 만료되었을 때
|
||||
- 그 외에는 내부 캐시를 사용합니다.
|
||||
- 백그라운드 worker가 TTL보다 짧은 주기로 `jwksUri`를 선제 점검해 첫 사용자 실패를 줄입니다.
|
||||
|
||||
#### DevFront 운영 액션
|
||||
- Settings > `Public Key Registration` 카드에서 다음 정보를 확인할 수 있습니다.
|
||||
- 최근 JWKS 캐시 갱신 시각
|
||||
- 최근 검증 성공 시각
|
||||
- 최근 에러와 연속 실패 횟수
|
||||
- 현재 cached `kid` 목록
|
||||
- 파싱된 key summary
|
||||
- `kid`
|
||||
- `kty`
|
||||
- `use`
|
||||
- `alg`
|
||||
- RSA key의 `n` preview (앞/뒤 일부만 표시)
|
||||
- 수동 운영 액션:
|
||||
- `Refresh JWKS Cache`
|
||||
- `Revoke JWKS Cache`
|
||||
- RP가 키를 교체했으면 실제 트래픽 전에 `Refresh JWKS Cache`를 먼저 호출하는 것을 권장합니다.
|
||||
|
||||
#### 일반 로그인과의 차이
|
||||
- `POST /api/v1/auth/password/login`
|
||||
- UserFront 기본 비밀번호 로그인용입니다.
|
||||
- `login_challenge`가 없으면 `sessionJwt`를 반환합니다.
|
||||
- `login_challenge`가 있으면 Hydra accept 후 `redirectTo`를 반환합니다.
|
||||
- `POST /api/v1/auth/headless/password/login`
|
||||
- headless login 허용 클라이언트 전용입니다.
|
||||
- `client_assertion` 검증이 추가됩니다.
|
||||
- 항상 `sessionJwt` 없이 `redirectTo`만 반환합니다.
|
||||
|
||||
#### 실패 패턴 요약
|
||||
- `400 bad_request`
|
||||
- 필수 필드 누락
|
||||
- `client_assertion` 누락
|
||||
- `401 invalid_client_assertion`
|
||||
- `jwksUri` 조회 실패
|
||||
- 서명 불일치
|
||||
- `aud`/`iss`/`sub`/`exp` 검증 실패
|
||||
- `401 invalid_client_assertion` with explicit message
|
||||
- `Headless login requires jwksUri. Inline jwks is not supported.`
|
||||
- `Configured jwksUri returned no keys for headless login.`
|
||||
- `Failed to refresh headless login jwks from jwksUri.`
|
||||
- `403 forbidden`
|
||||
- `client_id` 불일치
|
||||
- `headless_login_enabled` 미설정
|
||||
- inactive client
|
||||
- `401 password_or_email_mismatch`
|
||||
- 사용자 인증 실패
|
||||
|
||||
|
||||
### 전체 연결 구조도
|
||||
|
||||
@@ -147,7 +285,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
|
||||
|
||||
### 사전 요구사항 (Prerequisites)
|
||||
- Docker & Docker Compose
|
||||
- Flutter SDK (로컬 개발용)
|
||||
- Flutter SDK (로컬 개발용, 3.38.0+)
|
||||
- Go (로컬 백엔드 개발용)
|
||||
|
||||
### 환경 설정 (Environment Setup)
|
||||
@@ -155,13 +293,72 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
```
|
||||
2. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 입니다
|
||||
```
|
||||
IDP_PROVIDER=ory
|
||||
KRATOS_ADMIN_URL=http://kratos:4434
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
HYDRA_PUBLIC_URL=http://hydra:4444
|
||||
```
|
||||
2. `.env`를 작성합니다. (아래 작성 규칙 필수)
|
||||
|
||||
### `.env` 작성 규칙 (중요)
|
||||
- `KEY=value` 한 줄만 사용하고, **값 뒤에 같은 줄 주석을 붙이지 않습니다.**
|
||||
- 주석이 필요하면 반드시 **윗줄에 별도 주석 라인**으로 작성합니다.
|
||||
- URL 값 끝에 공백이 들어가면 Hydra/Kratos 기동 실패로 이어질 수 있습니다.
|
||||
|
||||
잘못된 예:
|
||||
```env
|
||||
USERFRONT_URL=https://sso.example.com # 이렇게 같은 줄 주석 금지
|
||||
```
|
||||
|
||||
올바른 예:
|
||||
```env
|
||||
# UserFront 공개 URL
|
||||
USERFRONT_URL=https://sso.example.com
|
||||
```
|
||||
|
||||
### `.env` 핵심 변수 가이드
|
||||
- `IDP_PROVIDER`: 기본 `ory`
|
||||
- `USERFRONT_URL`: 브라우저 기준 공개 도메인 (예: `https://sso.example.com`)
|
||||
- `OATHKEEPER_PUBLIC_URL`: 보통 `${USERFRONT_URL}`
|
||||
- `HYDRA_PUBLIC_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/oidc`
|
||||
- `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
|
||||
- `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`)
|
||||
- `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`)
|
||||
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`)
|
||||
- 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다.
|
||||
- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택)
|
||||
- 빈값: `[]`
|
||||
- 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback`
|
||||
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
||||
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
||||
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
||||
- `BACKEND_LOG_LEVEL`: Backend `slog` 레벨 override (선택)
|
||||
- 허용 값: `debug`, `info`, `warn`, `error`
|
||||
- 미설정 시 `APP_ENV` 기준으로 결정됩니다.
|
||||
- `dev|local|development`: `debug`
|
||||
- 그 외(`stage`, `production`, `prod` 등): `info`
|
||||
- `USERFRONT_DEBUG_LOG`: UserFront 측 디버그 로그 fallback 플래그
|
||||
- `CLIENT_LOG_DEBUG`가 없을 때만 UserFront에서 대체로 참조
|
||||
|
||||
### 클라이언트 로그 정책 (중요)
|
||||
- 기본 원칙: 운영 환경에서는 민감정보 보호를 우선하며, 과도한 로그 수집을 제한합니다.
|
||||
- 환경별 동작:
|
||||
- 비운영(`dev/local/stage` 등): 디버그 로그 허용 (`INFO/DEBUG/WARN/ERROR`)
|
||||
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG` 미설정: `WARN/ERROR`만 수집
|
||||
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG=true`: 디버그 로그 허용
|
||||
- 민감정보는 환경과 무관하게 마스킹합니다.
|
||||
- 예: `password`, `newPassword`, `token`, `authorization`, `cookie`, `sessionJwt`
|
||||
- 문자열 패턴(`token=...`, `authorization:...`, JSON body 내 민감 key)도 마스킹
|
||||
- 상세 정책 문서: `docs/client-log-policy.md`
|
||||
|
||||
### Backend 로그 정책 (중요)
|
||||
- Backend 서버 로그는 `APP_ENV` 기준으로 기본 레벨이 정해집니다.
|
||||
- `dev|local|development`: `debug`
|
||||
- 그 외(`stage`, `production`, `prod` 등): `info`
|
||||
- 운영/스테이징에서 장애 분석이 필요할 때만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정하는 것을 권장합니다.
|
||||
- headless login 같은 경로의 상세 진단 필드는 `debug` 레벨에서만 추가로 남습니다.
|
||||
- 상세 정책 문서: `docs/backend-log-policy.md`
|
||||
|
||||
### `.env` 작성 후 권장 점검
|
||||
```bash
|
||||
make validate-auth-config
|
||||
```
|
||||
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
|
||||
|
||||
### 전체 스택 실행 (Running the Stack)
|
||||
|
||||
@@ -178,15 +375,49 @@ docker network create public_net #서비스용
|
||||
#### 2. 인프라 및 Ory Stack 실행
|
||||
데이터베이스와 Ory 서비스(Kratos, Hydra, Keto 등)를 실행합니다.
|
||||
```bash
|
||||
docker compose -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||
# 권장: Make 실행 (인증 설정 검증 포함)
|
||||
make up-dev
|
||||
```
|
||||
|
||||
#### 3. 애플리케이션 실행
|
||||
userfront와 backend 서비스를 실행합니다.
|
||||
```bash
|
||||
docker compose -f docker-compose.yaml up -d
|
||||
make up-app
|
||||
```
|
||||
(또는 전체 스택 한번에 실행: `make up-all`)
|
||||
|
||||
### Make 기반 인증 설정 검증 (권장)
|
||||
`up-*` 타깃은 실행 전 인증 리다이렉트 설정을 자동 검증합니다.
|
||||
|
||||
```bash
|
||||
# 1) 인증 설정 생성
|
||||
make build-auth-config
|
||||
|
||||
# 2) 정적 검증 (callback / allowed_return_urls / 게이트웨이 매핑)
|
||||
make validate-auth-config
|
||||
|
||||
# 3) 배선 + (가능 시) 런타임 Hydra client 검증
|
||||
make verify-auth-config
|
||||
```
|
||||
|
||||
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
|
||||
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
|
||||
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
|
||||
|
||||
### 권장 실행 순서
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
# .env 편집
|
||||
make validate-auth-config
|
||||
make up-dev
|
||||
make up-app
|
||||
```
|
||||
|
||||
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
|
||||
```bash
|
||||
docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||
docker compose --env-file .env --env-file .generated/auth-config.env -f docker-compose.yaml up -d
|
||||
```
|
||||
(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`)
|
||||
|
||||
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
|
||||
- **backend**: http://localhost:3000 (API)
|
||||
@@ -231,6 +462,71 @@ KETO_READ_URL = "http://keto:4466"
|
||||
KETO_WRITE_URL = "http://keto:4467"
|
||||
```
|
||||
|
||||
## 🌐 i18n 구조 (간략)
|
||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
||||
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
||||
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||
|
||||
## 🧪 Code Check CI
|
||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||
|
||||
### 트리거
|
||||
- `push` (`dev` 브랜치)
|
||||
- `pull_request` (`dev` 대상)
|
||||
- `workflow_dispatch` (수동 실행)
|
||||
|
||||
### workflow_dispatch 입력값
|
||||
- `run_lint`: Go/Flutter lint 실행 여부
|
||||
- `run_backend_tests`: backend 테스트 실행 여부
|
||||
- `run_userfront_tests`: userfront 테스트 실행 여부
|
||||
- `run_userfront_e2e_tests`: userfront WASM Playwright E2E 실행 여부
|
||||
- `run_adminfront_tests`: adminfront 테스트 실행 여부
|
||||
- `run_devfront_tests`: devfront 테스트 실행 여부
|
||||
|
||||
### 실행 잡
|
||||
- `lint`
|
||||
- `backend-tests`
|
||||
- `userfront-tests`
|
||||
- `userfront-e2e-tests`
|
||||
- `adminfront-tests`
|
||||
- `devfront-tests`
|
||||
|
||||
### 프런트 테스트 브라우저 프리비저닝 정책
|
||||
- `userfront-tests`
|
||||
- `flutter test`(VM)만 실행
|
||||
- `locale_storage` 정책 테스트는 엔진 단위로 통합되어 별도 브라우저 실행이 필요하지 않음
|
||||
- `adminfront-tests`, `devfront-tests`
|
||||
- Playwright 기반 테스트
|
||||
- `npx playwright install --with-deps`로 브라우저/OS 의존성을 사전 설치
|
||||
|
||||
### 실패 보고서 확인 방법
|
||||
테스트가 실패하면 다음이 자동 생성됩니다.
|
||||
- Job Summary: 실패 원인 요약(Markdown) 즉시 확인
|
||||
- Artifact: 상세 로그/리포트 다운로드
|
||||
- `backend-test-failure-report`
|
||||
- `userfront-test-failure-report`
|
||||
- `adminfront-test-failure-report`
|
||||
- `devfront-test-failure-report`
|
||||
|
||||
### userfront `locale_storage` 테스트 정책
|
||||
- `locale_storage_platform_test.dart`는 `LocaleStorageEngine` 기반 정책 테스트로 통합되었습니다.
|
||||
- 일반 `flutter test`(VM) 실행에 포함되며, 브라우저 전용 `kIsWeb` 케이스를 사용하지 않습니다.
|
||||
- 단일 파일만 확인하려면 다음 명령을 사용합니다.
|
||||
- `flutter test test/locale_storage_platform_test.dart`
|
||||
|
||||
### userfront WASM Playwright E2E
|
||||
- 워크스페이스: `userfront-e2e/`
|
||||
- 빌드+실행:
|
||||
- `cd userfront-e2e && npm run test:wasm`
|
||||
- 빌드 결과가 이미 있을 때:
|
||||
- `cd userfront-e2e && npm test`
|
||||
- Makefile 타깃:
|
||||
- `make code-check-userfront-e2e-tests`
|
||||
- 전수 인벤토리:
|
||||
- `https://gitea.hmac.kr/baron/baron-sso/wiki/UserFront-WASM-E2E-Inventory.-`
|
||||
|
||||
### 로컬 개발 (Manual)
|
||||
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||
@@ -247,6 +543,8 @@ go run cmd/server/main.go
|
||||
cd userfront
|
||||
flutter pub get
|
||||
flutter run -d chrome
|
||||
# 정책: 웹 빌드는 기본적으로 WASM 사용
|
||||
flutter build web --wasm
|
||||
```
|
||||
|
||||
**adminfront:**
|
||||
@@ -262,34 +560,12 @@ cd devfront
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
|
||||
```
|
||||
baron_sso/
|
||||
├── backend/ # Go Fiber 애플리케이션
|
||||
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||
│ └── Dockerfile
|
||||
├── userfront/ # Flutter 애플리케이션
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── adminfront/ # React 기반 관리
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||
├── .env.sample # 환경 설정 템플릿
|
||||
└── README.md # 본 파일
|
||||
```
|
||||
|
||||
## 📝 상태 및 로드맵 (Status & Roadmap)
|
||||
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
|
||||
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
|
||||
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
|
||||
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
|
||||
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
|
||||
## 버그 대응 대원칙 (필수)
|
||||
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
||||
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
||||
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
||||
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
||||
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
||||
|
||||
@@ -6,7 +6,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
||||
## 🏗 Architecture
|
||||
|
||||
### 1. Frontend (Flutter Web)
|
||||
- **Framework**: Flutter 3.32+
|
||||
- **Framework**: Flutter 3.38.0+
|
||||
- **Organization**: `kr.co.baroncs`
|
||||
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
|
||||
- **Features**:
|
||||
@@ -32,7 +32,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Flutter SDK (for local development)
|
||||
- Flutter SDK (for local development, 3.38.0+)
|
||||
- Go (for local backend development)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
@@ -16,10 +16,5 @@ COPY . .
|
||||
EXPOSE 5173
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
||||
echo 'Running in production mode...'; \
|
||||
npm run build && serve -s dist -l 5173; \
|
||||
else \
|
||||
echo 'Running in development mode...'; \
|
||||
npm run dev -- --host 0.0.0.0; \
|
||||
fi"
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
관리자 포털을 위한 React/Vite 기반 웹입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
|
||||
|
||||
## 주요 스택
|
||||
- React 19, Vite 7, TypeScript(strict)
|
||||
- React 19, Vite 8, TypeScript(strict)
|
||||
- React Router v6 (data router)
|
||||
- TanStack Query v5
|
||||
- Tailwind CSS v3 + shadcn/ui 컴포넌트(seed: Button/Card/Badge/Input/Table/Avatar)
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist", "node_modules", "tsconfig*.json"]
|
||||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"playwright-report"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
474
adminfront/i18n-scan-output.txt
Normal file
474
adminfront/i18n-scan-output.txt
Normal file
@@ -0,0 +1,474 @@
|
||||
|
||||
> adminfront@0.0.0 i18n-scan
|
||||
> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js
|
||||
|
||||
|
||||
ko.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
en.toml에 없는 키
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
|
||||
template.toml에 없는 코드 사용 키
|
||||
- msg.admin.users.detail.history_desc
|
||||
- msg.admin.users.detail.no_history
|
||||
- msg.admin.users.detail.no_tenants
|
||||
- msg.admin.users.detail.reset_auto_desc
|
||||
- msg.admin.users.detail.security_desc
|
||||
- msg.admin.users.detail.tenant_slug_help
|
||||
- msg.admin.users.detail.tenants_desc
|
||||
- msg.common.copied
|
||||
- msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- msg.dev.clients.general.public_key.cache_empty
|
||||
- msg.dev.clients.general.public_key.cache_help
|
||||
- msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- msg.dev.clients.general.public_key.cache_refreshed
|
||||
- msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- msg.dev.clients.general.public_key.cache_revoked
|
||||
- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.detail.form.email
|
||||
- ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.detail.history_title
|
||||
- ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.detail.password_done
|
||||
- ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.detail.updated_at
|
||||
- ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.dev.clients.general.public_key.cache.error
|
||||
- ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.dev.clients.general.public_key.cache.status
|
||||
- ui.dev.clients.general.public_key.cache.title
|
||||
- ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.dev.clients.general.public_key.revoke_cache
|
||||
|
||||
코드에서 사용되지 않는 키
|
||||
- err.backend.authorization_pending
|
||||
- err.backend.bad_request
|
||||
- err.backend.conflict
|
||||
- err.backend.expired_token
|
||||
- err.backend.forbidden
|
||||
- err.backend.internal_error
|
||||
- err.backend.invalid_code
|
||||
- err.backend.invalid_or_expired_code
|
||||
- err.backend.invalid_session
|
||||
- err.backend.invalid_session_reference
|
||||
- err.backend.not_found
|
||||
- err.backend.not_supported
|
||||
- err.backend.password_or_email_mismatch
|
||||
- err.backend.rate_limited
|
||||
- err.backend.service_unavailable
|
||||
- err.backend.slow_down
|
||||
- msg.admin.groups.create.description
|
||||
- msg.admin.groups.create.title
|
||||
- msg.admin.groups.list.import_error
|
||||
- msg.admin.groups.list.import_success
|
||||
- msg.admin.header.subtitle
|
||||
- msg.admin.idp_env_prod
|
||||
- msg.admin.notice.idp_policy
|
||||
- msg.admin.notice.scope
|
||||
- msg.admin.overview.idp_fallback
|
||||
- msg.admin.overview.idp_primary
|
||||
- msg.admin.overview.playbook.description
|
||||
- msg.admin.overview.playbook.idp_body
|
||||
- msg.admin.overview.playbook.idp_title
|
||||
- msg.admin.overview.playbook.tenant_body
|
||||
- msg.admin.overview.playbook.tenant_title
|
||||
- msg.admin.overview.quick_links.description
|
||||
- msg.admin.overview.summary.audit_events_24h
|
||||
- msg.admin.overview.summary.oidc_clients
|
||||
- msg.admin.overview.summary.policy_gate
|
||||
- msg.admin.overview.summary.total_tenants
|
||||
- msg.admin.scope_admin
|
||||
- msg.admin.session_ttl
|
||||
- msg.admin.tenant_headers
|
||||
- msg.admin.users.create.form.login_id_help
|
||||
- msg.admin.users.detail.delete_error
|
||||
- msg.admin.users.detail.password_generated_help
|
||||
- msg.admin.users.detail.reset_password_confirm
|
||||
- msg.admin.users.detail.security.password_hint
|
||||
- msg.admin.users.detail.update_success
|
||||
- msg.common.copied_to_clipboard
|
||||
- msg.dev.audit.forbidden
|
||||
- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help
|
||||
- msg.dev.clients.general.public_key.auth_method_none_help
|
||||
- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help
|
||||
- msg.dev.clients.general.public_key.guide_example
|
||||
- msg.dev.clients.general.public_key.guide_intro
|
||||
- msg.dev.clients.general.public_key.guide_step_1
|
||||
- msg.dev.clients.general.public_key.guide_step_2
|
||||
- msg.dev.clients.general.public_key.guide_step_3
|
||||
- msg.dev.clients.general.public_key.jwks_inline_help
|
||||
- msg.dev.clients.general.public_key.request_object_alg_help
|
||||
- msg.dev.clients.general.public_key.source_help
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_alg
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt
|
||||
- msg.dev.clients.general.public_key.validation.headless_requires_public_key
|
||||
- msg.dev.clients.general.public_key.validation.invalid_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.missing_jwks_inline
|
||||
- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key
|
||||
- msg.userfront.signup.privacy_full
|
||||
- msg.userfront.signup.tos_full
|
||||
- non.existent.key
|
||||
- test.key
|
||||
- ui.admin.api_keys.list.breadcrumb.list
|
||||
- ui.admin.api_keys.list.breadcrumb.section
|
||||
- ui.admin.audit.breadcrumb.logs
|
||||
- ui.admin.audit.breadcrumb.section
|
||||
- ui.admin.groups.import_csv
|
||||
- ui.admin.overview.kicker
|
||||
- ui.admin.overview.playbook.title
|
||||
- ui.admin.overview.quick_links.add_tenant
|
||||
- ui.admin.overview.quick_links.api_key_management
|
||||
- ui.admin.overview.quick_links.user_management
|
||||
- ui.admin.overview.quick_links.view_audit_logs
|
||||
- ui.admin.tenants.breadcrumb.list
|
||||
- ui.admin.tenants.breadcrumb.section
|
||||
- ui.admin.tenants.create.breadcrumb.action
|
||||
- ui.admin.tenants.create.breadcrumb.section
|
||||
- ui.admin.tenants.detail.breadcrumb_list
|
||||
- ui.admin.tenants.detail.title
|
||||
- ui.admin.users.create.breadcrumb.new
|
||||
- ui.admin.users.create.breadcrumb.section
|
||||
- ui.admin.users.create.form.login_id
|
||||
- ui.admin.users.create.form.login_id_placeholder
|
||||
- ui.admin.users.detail.breadcrumb.section
|
||||
- ui.admin.users.detail.contact_title
|
||||
- ui.admin.users.detail.form.department_placeholder
|
||||
- ui.admin.users.detail.form.job_title_placeholder
|
||||
- ui.admin.users.detail.form.login_id
|
||||
- ui.admin.users.detail.form.login_id_placeholder
|
||||
- ui.admin.users.detail.form.name_placeholder
|
||||
- ui.admin.users.detail.form.phone_placeholder
|
||||
- ui.admin.users.detail.form.position_placeholder
|
||||
- ui.admin.users.detail.form.status_active
|
||||
- ui.admin.users.detail.form.status_inactive
|
||||
- ui.admin.users.detail.generate_password
|
||||
- ui.admin.users.detail.password_mode_generated
|
||||
- ui.admin.users.detail.password_mode_manual
|
||||
- ui.admin.users.detail.password_result_title
|
||||
- ui.admin.users.detail.reset_password_apply
|
||||
- ui.admin.users.detail.security.password
|
||||
- ui.admin.users.detail.security.password_placeholder
|
||||
- ui.admin.users.detail.security.title
|
||||
- ui.admin.users.detail.status_title
|
||||
- ui.admin.users.detail.tenants_section.additional
|
||||
- ui.admin.users.detail.tenants_section.primary
|
||||
- ui.admin.users.detail.tenants_section.title
|
||||
- ui.admin.users.detail.title
|
||||
- ui.admin.users.detail.toggle_password_visibility
|
||||
- ui.admin.users.list.breadcrumb.list
|
||||
- ui.admin.users.list.breadcrumb.section
|
||||
- ui.admin.users.list.empty
|
||||
- ui.admin.users.list.fetch_error
|
||||
- ui.admin.users.list.registry.count
|
||||
- ui.admin.users.list.subtitle
|
||||
- ui.admin.users.list.table.login_id
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_history
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
|
||||
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
|
||||
- ui.admin.users.list.table.msg.common.copied
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
|
||||
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
|
||||
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.email
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.history_title
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.password_done
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
|
||||
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
|
||||
- ui.admin.users.list.table.ui.common.generate
|
||||
- ui.admin.users.list.table.ui.common.status.blocked
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
|
||||
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
|
||||
- ui.admin.users.list.table.ui.dev.clients.registry.description
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.email
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
|
||||
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
|
||||
- ui.admin.users.list.table.ui.dev.session.refresh
|
||||
- ui.admin.users.list.table.ui.dev.session.refreshing
|
||||
- ui.common.generate
|
||||
- ui.common.status.blocked
|
||||
- ui.dev.clients.general.public_key.auth_method
|
||||
- ui.dev.clients.general.public_key.auth_method_client_secret_basic
|
||||
- ui.dev.clients.general.public_key.auth_method_none
|
||||
- ui.dev.clients.general.public_key.auth_method_private_key_jwt
|
||||
- ui.dev.clients.general.public_key.guide_toggle
|
||||
- ui.dev.clients.general.public_key.headless_disabled
|
||||
- ui.dev.clients.general.public_key.headless_enabled
|
||||
- ui.dev.clients.general.public_key.jwks_inline
|
||||
- ui.dev.clients.general.public_key.jwks_inline_placeholder
|
||||
- ui.dev.clients.general.public_key.request_object_alg
|
||||
- ui.dev.clients.general.public_key.request_object_alg_placeholder
|
||||
- ui.dev.clients.general.public_key.source
|
||||
- ui.dev.clients.general.public_key.source_uri
|
||||
- ui.dev.clients.general.security.trusted_rp_enable
|
||||
- ui.dev.clients.general.security.trusted_rp_enable_help
|
||||
- ui.dev.clients.help.docs_body
|
||||
- ui.dev.clients.help.subtitle
|
||||
- ui.dev.clients.registry.description
|
||||
- ui.dev.clients.scopes.email
|
||||
- ui.dev.clients.scopes.openid
|
||||
- ui.dev.clients.scopes.profile
|
||||
- ui.dev.session.refresh
|
||||
- ui.dev.session.refreshing
|
||||
|
||||
요약
|
||||
- [Sync Error] ko.toml 누락 키 84개
|
||||
- [Sync Error] en.toml 누락 키 84개
|
||||
- [Missing Key] template.toml 누락 키 59개
|
||||
4170
adminfront/package-lock.json
generated
4170
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,27 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui"
|
||||
"test": "npx playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
@@ -24,9 +32,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -34,18 +44,21 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,13 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const port = Number.parseInt(process.env.PORT ?? "5173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
@@ -13,6 +21,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
timeout: 15000,
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
@@ -20,16 +32,17 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:5173",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
trace: "retain-on-failure",
|
||||
locale: "ko-KR",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
@@ -51,9 +64,14 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`
|
||||
: `npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: defaultBaseUrl,
|
||||
reuseExistingServer,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
26
adminfront/scripts/runtime-mode.sh
Normal file
26
adminfront/scripts/runtime-mode.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$app_env" in
|
||||
production|prod|stage|staging)
|
||||
mode="production"
|
||||
;;
|
||||
*)
|
||||
mode="development"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = "--print-mode" ]; then
|
||||
printf '%s\n' "$mode"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$mode" = "production" ]; then
|
||||
echo "Running in production mode with Vite preview..."
|
||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
||||
fi
|
||||
|
||||
echo "Running in development mode..."
|
||||
exec npm run dev -- --host 0.0.0.0
|
||||
@@ -3,26 +3,37 @@ import AppLayout from "../components/layout/AppLayout";
|
||||
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
import UserDetailPage from "../features/users/UserDetailPage";
|
||||
import UserListPage from "../features/users/UserListPage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/auth/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "dashboard", element: <DashboardPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
@@ -35,18 +46,19 @@ export const router = createBrowserRouter(
|
||||
element: <TenantDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
element: <TenantUserGroupsTab />,
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
||||
{
|
||||
future: {
|
||||
v7_startTransition: true,
|
||||
},
|
||||
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||
// React Router v7 플래그는 Provider에서 적용합니다.
|
||||
);
|
||||
|
||||
40
adminfront/src/components/auth/RoleGuard.tsx
Normal file
40
adminfront/src/components/auth/RoleGuard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
roles: string[];
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoleGuard conditionally renders children based on the current user's role.
|
||||
*
|
||||
* Usage:
|
||||
* <RoleGuard roles={['super_admin']}>
|
||||
* <button>System Only Action</button>
|
||||
* </RoleGuard>
|
||||
*/
|
||||
export function RoleGuard({
|
||||
children,
|
||||
roles,
|
||||
fallback = null,
|
||||
}: RoleGuardProps) {
|
||||
const { data: profile, isLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
53
adminfront/src/components/common/LanguageSelector.tsx
Normal file
53
adminfront/src/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
function resolveLocale(): Locale {
|
||||
if (typeof window === "undefined") {
|
||||
return "ko";
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored === "ko" || stored === "en") {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale === "ko" || pathLocale === "en") {
|
||||
return pathLocale;
|
||||
}
|
||||
|
||||
const browserLang = window.navigator.language.toLowerCase();
|
||||
return browserLang.startsWith("ko") ? "ko" : "en";
|
||||
}
|
||||
|
||||
function LanguageSelector() {
|
||||
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||
|
||||
const handleChange = (next: Locale) => {
|
||||
if (next === locale) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
setLocale(next);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(event) => handleChange(event.target.value as Locale)}
|
||||
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label={t("ui.common.language", "언어")}
|
||||
>
|
||||
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
|
||||
<option value="en">{t("ui.common.language_en", "English")}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,148 +1,738 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
Users,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||
{ label: "Users", to: "/users", icon: Users },
|
||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const staticNavItems: NavItem[] = [
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const isDevRoleOverrideEnabled =
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const isMockRoleEnabled =
|
||||
isDevRoleOverrideEnabled &&
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRoleOverride = isMockRoleEnabled
|
||||
? window.localStorage.getItem("X-Mock-Role")
|
||||
: null;
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||
return stored !== "false";
|
||||
});
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: profile,
|
||||
isLoading: isProfileLoading,
|
||||
error: profileError,
|
||||
} = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
console.debug("[AppLayout] Fetching profile...");
|
||||
try {
|
||||
const data = await fetchMe();
|
||||
console.debug("[AppLayout] Profile fetched successfully:", data.email);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[AppLayout] Failed to fetch profile:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
(auth.isAuthenticated && !auth.isLoading) ||
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
|
||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
return filteredItems;
|
||||
}, [mockRoleOverride, profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
|
||||
) {
|
||||
window.localStorage.removeItem("admin_session");
|
||||
auth.removeUser();
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
console.debug("[AppLayout] Auth state check:", {
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isTest,
|
||||
});
|
||||
|
||||
if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
|
||||
console.warn("[AppLayout] Not authenticated, redirecting to /login");
|
||||
navigate("/login");
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.user?.access_token) {
|
||||
window.localStorage.setItem("admin_session", auth.user.access_token);
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
profileMenuRef.current &&
|
||||
!profileMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsProfileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Baron 로그인
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Admin Control
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||
<BadgeCheck size={14} />
|
||||
Scoped to /admin
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
IDP env: prod
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
Tenant-aware headers
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||
<p>관리 기능은 /admin 네임스페이스에서만 노출합니다.</p>
|
||||
<p>
|
||||
IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·
|
||||
레이트리밋을 기본 적용합니다.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Admin Plane
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
Tenant isolation & least privilege by default
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Moon size={16} />
|
||||
)}
|
||||
{theme === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||
Session TTL: 15m admin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
const maybeRenewSession = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAction = () => {
|
||||
void maybeRenewSession();
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handleUserAction);
|
||||
window.addEventListener("keydown", handleUserAction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handleUserAction);
|
||||
window.removeEventListener("keydown", handleUserAction);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void maybeKeepSessionAlive();
|
||||
}, 30_000);
|
||||
|
||||
void maybeKeepSessionAlive();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||
if (lastVisitedRouteRef.current === null) {
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVisitedRouteRef.current === routeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
void auth
|
||||
.signinSilent()
|
||||
.catch((error) => {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
})
|
||||
.finally(() => {
|
||||
isRenewInFlightRef.current = false;
|
||||
});
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
location.hash,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
profile?.name?.trim() ||
|
||||
auth.user?.profile.name?.toString().trim() ||
|
||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
profile?.email?.trim() ||
|
||||
auth.user?.profile.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||
const expiresAtSec = auth.user?.expires_at;
|
||||
const remainingMs =
|
||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||
const remainingTotalSec =
|
||||
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
|
||||
const remainingMinutes =
|
||||
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
|
||||
const remainingSeconds =
|
||||
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
|
||||
|
||||
let sessionToneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let sessionText = t("ui.dev.session.active", "세션 활성");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "알 수 없음");
|
||||
} else if (remainingMs <= 0) {
|
||||
sessionToneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료");
|
||||
} else if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.admin.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.admin.title", "Admin Control")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map((item: NavItem) => {
|
||||
const { label, to, icon: Icon, isExternal } = item;
|
||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||
const isTenantsRoot = to === "/tenants";
|
||||
const isCustomActive = isTenantsRoot
|
||||
? location.pathname.startsWith("/tenants") && !isOrgChart
|
||||
: to === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(to);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={() =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isCustomActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
{t("ui.admin.header.subtitle", "Manage your organization")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{theme === "light"
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileOpen((prev) => !prev)}
|
||||
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isProfileOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.admin.role.${profileRoleKey}`,
|
||||
profileRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isSessionExpiryEnabled}
|
||||
onClick={handleSessionExpiryToggle}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
|
||||
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block h-5 w-5 rounded-full bg-white transition",
|
||||
isSessionExpiryEnabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-1",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile?.manageableTenants &&
|
||||
profile.manageableTenants.length > 0 ? (
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.profile.manageable_tenants",
|
||||
"Manageable Tenants",
|
||||
)}
|
||||
</p>
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
|
||||
{profile.manageableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(`/tenants/${tenant.id}`);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
{tenant.type === "USER_GROUP" ? (
|
||||
<Users size={13} />
|
||||
) : (
|
||||
<Building2 size={13} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{tenant.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(
|
||||
`/users/${profile?.id || auth.user?.profile.sub}`,
|
||||
);
|
||||
}}
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -1,66 +1,173 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const RoleSwitcher: React.FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>('super_admin');
|
||||
const RoleSwitcher: FC = () => {
|
||||
const [currentRole, setCurrentRole] = useState<string>("");
|
||||
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// localStorage에서 역할 읽기
|
||||
const savedRole = window.localStorage.getItem('X-Mock-Role');
|
||||
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||
const savedEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
setIsOverrideEnabled(savedEnabled);
|
||||
if (savedRole) {
|
||||
setCurrentRole(savedRole);
|
||||
} else {
|
||||
// 기본값 설정
|
||||
window.localStorage.setItem('X-Mock-Role', 'super_admin');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
const nextState = !isCollapsed;
|
||||
setIsCollapsed(nextState);
|
||||
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
|
||||
};
|
||||
|
||||
const switchRole = (role: string) => {
|
||||
// localStorage 설정
|
||||
window.localStorage.setItem('X-Mock-Role', role);
|
||||
window.localStorage.setItem("X-Mock-Role", role);
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
setCurrentRole(role);
|
||||
// 페이지 새로고침하여 권한 적용
|
||||
setIsOverrideEnabled(true);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (import.meta.env.MODE === 'production') return null;
|
||||
const clearRoleOverride = () => {
|
||||
window.localStorage.removeItem("X-Mock-Role-Enabled");
|
||||
setIsOverrideEnabled(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (import.meta.env.MODE === "production") return null;
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
|
||||
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
|
||||
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
|
||||
user: t("ui.admin.role.user", "TENANT MEMBER"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 9999,
|
||||
background: '#1A1F2C',
|
||||
color: 'white',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
|
||||
🛠 DEV Role Switcher
|
||||
</div>
|
||||
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => switchRole(role)}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
background: "#1A1F2C",
|
||||
color: "white",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: isCollapsed ? "0" : "8px",
|
||||
fontSize: "12px",
|
||||
transition: "all 0.3s ease",
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
paddingBottom: isCollapsed ? "0" : "4px",
|
||||
borderBottom: isCollapsed ? "none" : "1px solid #444",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
color: "inherit",
|
||||
textAlign: "inherit",
|
||||
}}
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<Wrench size={14} className="text-blue-400" />
|
||||
{!isCollapsed && (
|
||||
<span>{t("ui.admin.dev_role_switcher", "DEV Role Switcher")}</span>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<span style={{ fontSize: "10px", color: "#888" }}>
|
||||
{isOverrideEnabled && currentRole
|
||||
? currentRole.toUpperCase()
|
||||
: "REAL ROLE"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
style={{
|
||||
background: currentRole === role ? '#3b82f6' : '#333',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.2s'
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRoleOverride}
|
||||
style={{
|
||||
background: !isOverrideEnabled ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
|
||||
</span>
|
||||
{!isOverrideEnabled && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
|
||||
(role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => switchRole(role)}
|
||||
style={{
|
||||
background: currentRole === role ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
|
||||
</span>
|
||||
{isOverrideEnabled && currentRole === role && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
26
adminfront/src/components/ui/badge.test.tsx
Normal file
26
adminfront/src/components/ui/badge.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
describe("Badge Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Badge>Active</Badge>);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies variant classes correctly", () => {
|
||||
const { rerender } = render(<Badge variant="secondary">Secondary</Badge>);
|
||||
let badge = screen.getByText("Secondary");
|
||||
expect(badge).toHaveClass("bg-secondary");
|
||||
|
||||
rerender(<Badge variant="outline">Default</Badge>);
|
||||
badge = screen.getByText("Default");
|
||||
expect(badge).toHaveClass("text-foreground");
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<Badge className="custom-class">Custom</Badge>);
|
||||
const badge = screen.getByText("Custom");
|
||||
expect(badge).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
38
adminfront/src/components/ui/button.test.tsx
Normal file
38
adminfront/src/components/ui/button.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /click me/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies variant classes correctly", () => {
|
||||
const { rerender } = render(<Button variant="destructive">Delete</Button>);
|
||||
const button = screen.getByRole("button", { name: /delete/i });
|
||||
expect(button).toHaveClass("bg-destructive");
|
||||
|
||||
rerender(<Button variant="outline">Cancel</Button>);
|
||||
const outlineButton = screen.getByRole("button", { name: /cancel/i });
|
||||
expect(outlineButton).toHaveClass("border-input");
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Button onClick={onClick}>Click me</Button>);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /click me/i }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is disabled when the disabled prop is passed", () => {
|
||||
render(<Button disabled>Disabled Button</Button>);
|
||||
const button = screen.getByRole("button", { name: /disabled button/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
35
adminfront/src/components/ui/card.test.tsx
Normal file
35
adminfront/src/components/ui/card.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
|
||||
describe("Card Component", () => {
|
||||
it("renders card structure correctly", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Card Content</CardContent>
|
||||
<CardFooter>Card Footer</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Card Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom className to Card", () => {
|
||||
const { container } = render(<Card className="custom-card" />);
|
||||
expect(container.firstChild).toHaveClass("custom-card");
|
||||
});
|
||||
});
|
||||
31
adminfront/src/components/ui/checkbox.tsx
Normal file
31
adminfront/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
|
||||
onCheckedChange?: (checked: boolean | "indeterminate") => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheckedChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",
|
||||
className,
|
||||
)}
|
||||
onChange={handleChange}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
export { Checkbox };
|
||||
120
adminfront/src/components/ui/dialog.tsx
Normal file
120
adminfront/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
200
adminfront/src/components/ui/dropdown-menu.tsx
Normal file
200
adminfront/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-tighter opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
28
adminfront/src/components/ui/input.test.tsx
Normal file
28
adminfront/src/components/ui/input.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input Component", () => {
|
||||
it("renders correctly", () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles value changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Input placeholder="Enter text" onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText("Enter text");
|
||||
|
||||
await user.type(input, "Hello");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(input).toHaveValue("Hello");
|
||||
});
|
||||
|
||||
it("is disabled when the disabled prop is passed", () => {
|
||||
render(<Input disabled />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
27
adminfront/src/components/ui/label.test.tsx
Normal file
27
adminfront/src/components/ui/label.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Label>Username</Label>);
|
||||
expect(screen.getByText("Username")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<Label className="custom-label">Password</Label>);
|
||||
const label = screen.getByText("Password");
|
||||
expect(label).toHaveClass("custom-label");
|
||||
});
|
||||
|
||||
it("is associated with an input via htmlFor", () => {
|
||||
render(
|
||||
<>
|
||||
<Label htmlFor="test-input">Label Text</Label>
|
||||
<input id="test-input" />
|
||||
</>,
|
||||
);
|
||||
const label = screen.getByText("Label Text");
|
||||
expect(label).toHaveAttribute("for", "test-input");
|
||||
});
|
||||
});
|
||||
158
adminfront/src/components/ui/select.tsx
Normal file
158
adminfront/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=top]:slide-in-from-bottom-2 data-[state=bottom]:slide-in-from-top-2",
|
||||
position === "popper" &&
|
||||
"data-[state=open]:slide-in-from-top-2 data-[state=bottom]:translate-y-1 data-[state=left]:-translate-x-1 data-[state=right]:translate-x-1 data-[state=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
@@ -69,7 +69,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
||||
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-foreground align-middle sticky top-0 bg-inherit",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
87
adminfront/src/components/ui/tabs.tsx
Normal file
87
adminfront/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}>({});
|
||||
|
||||
const Tabs = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
>(({ className, value, onValueChange, ...props }, ref) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div ref={ref} className={cn("w-full", className)} {...props} />
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
});
|
||||
Tabs.displayName = "Tabs";
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
data-state={isSelected ? "active" : "inactive"}
|
||||
onClick={() => onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
if (!isSelected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
35
adminfront/src/components/ui/toaster.tsx
Normal file
35
adminfront/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastState } from "./use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastState();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||
t.type === "success" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||
t.type === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||
t.type === "info" &&
|
||||
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
|
||||
)}
|
||||
>
|
||||
{t.type === "success" && (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
adminfront/src/components/ui/use-toast.ts
Normal file
66
adminfront/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ToastType = "success" | "error" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
let subscribers: ((toasts: Toast[]) => void)[] = [];
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
const notify = () => {
|
||||
for (const sub of subscribers) {
|
||||
sub(toasts);
|
||||
}
|
||||
};
|
||||
|
||||
const toastBase = (message: string, type: ToastType = "success") => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
notify();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
export const toast = Object.assign(toastBase, {
|
||||
success: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "success");
|
||||
},
|
||||
error: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "error");
|
||||
},
|
||||
info: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "info");
|
||||
},
|
||||
});
|
||||
|
||||
export const useToastState = () => {
|
||||
const [state, setState] = React.useState<Toast[]>(toasts);
|
||||
|
||||
React.useEffect(() => {
|
||||
subscribers.push(setState);
|
||||
return () => {
|
||||
subscribers = subscribers.filter((sub) => sub !== setState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
Loader2,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -13,24 +21,69 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
type ApiKeyCreateRequest,
|
||||
type ApiKeyCreateResponse,
|
||||
createApiKey,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
|
||||
|
||||
const AVAILABLE_SCOPES = [
|
||||
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
|
||||
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
|
||||
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
|
||||
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
|
||||
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
|
||||
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
function ApiKeyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
|
||||
const [createdResult, setCreatedResult] =
|
||||
React.useState<ApiKeyCreateResponse | null>(null);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
|
||||
"audit:read",
|
||||
"user:read",
|
||||
]);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -47,19 +100,29 @@ function ApiKeyCreatePage() {
|
||||
setCreatedResult(data);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleScope = (scopeId: string) => {
|
||||
setSelectedScopes((prev) =>
|
||||
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
|
||||
prev.includes(scopeId)
|
||||
? prev.filter((s) => s !== scopeId)
|
||||
: [...prev, scopeId],
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (data: { name: string }) => {
|
||||
if (selectedScopes.length === 0) {
|
||||
setError("최소 하나 이상의 권한을 선택해야 합니다.");
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.api_keys.create.scope_required",
|
||||
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -77,9 +140,24 @@ function ApiKeyCreatePage() {
|
||||
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">API 키 생성 완료</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
아래의 비밀번호(Secret)는 보안을 위해 <span className="text-destructive font-bold">지금 한 번만</span> 표시됩니다.
|
||||
{t(
|
||||
"msg.admin.api_keys.create.success.notice",
|
||||
"아래의 비밀번호(Secret)는 보안을 위해 ",
|
||||
)}
|
||||
<span className="text-destructive font-bold">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.success.notice_emphasis",
|
||||
"지금 한 번만",
|
||||
)}
|
||||
</span>{" "}
|
||||
{t(
|
||||
"msg.admin.api_keys.create.success.notice_suffix",
|
||||
"표시됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +165,10 @@ function ApiKeyCreatePage() {
|
||||
<CardHeader className="bg-primary/5 border-b">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-primary" />
|
||||
보안 시크릿 복사
|
||||
{t(
|
||||
"ui.admin.api_keys.create.success.copy_secret",
|
||||
"보안 시크릿 복사",
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-8 pb-8 space-y-6">
|
||||
@@ -96,14 +177,14 @@ function ApiKeyCreatePage() {
|
||||
X-Baron-Key-Secret
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
readOnly
|
||||
value={createdResult.clientSecret}
|
||||
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
||||
<Input
|
||||
readOnly
|
||||
value={createdResult.clientSecret}
|
||||
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
|
||||
onClick={() => handleCopy(createdResult.clientSecret)}
|
||||
>
|
||||
@@ -111,13 +192,21 @@ function ApiKeyCreatePage() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-center text-muted-foreground italic">
|
||||
복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.
|
||||
{t(
|
||||
"msg.admin.api_keys.create.success.copy_hint",
|
||||
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col gap-2">
|
||||
<Button size="lg" className="w-full font-bold" asChild>
|
||||
<Link to="/api-keys">저장했습니다. 목록으로 이동</Link>
|
||||
<Link to="/api-keys">
|
||||
{t(
|
||||
"ui.admin.api_keys.create.success.go_list",
|
||||
"저장했습니다. 목록으로 이동",
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
|
||||
<div className="max-w-3xl mx-auto space-y-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 text-muted-foreground"
|
||||
onClick={() => navigate("/api-keys")}
|
||||
>
|
||||
<ChevronLeft size={16} className="mr-1" />
|
||||
돌아가기
|
||||
{t("ui.common.back", "돌아가기")}
|
||||
</Button>
|
||||
<h2 className="text-3xl font-bold tracking-tight">새 API 키 생성</h2>
|
||||
<p className="text-muted-foreground">내부 시스템 연동을 위한 보안 인증 키를 구성합니다.</p>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.subtitle",
|
||||
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -143,20 +244,41 @@ function ApiKeyCreatePage() {
|
||||
{/* 섹션 1: 이름 설정 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
|
||||
<h3 className="font-semibold text-lg">키 이름 지정</h3>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||
1
|
||||
</span>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
|
||||
</h3>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">서비스 또는 목적 식별 이름</Label>
|
||||
<Label htmlFor="name" className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.api_keys.create.name_label",
|
||||
"서비스 또는 목적 식별 이름",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="예: Jenkins-CI, Grafana-Dashboard"
|
||||
placeholder={t(
|
||||
"ui.admin.api_keys.create.name_placeholder",
|
||||
"예: Jenkins-CI, Grafana-Dashboard",
|
||||
)}
|
||||
className="text-base py-5"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
{...register("name", {
|
||||
required: t(
|
||||
"msg.admin.api_keys.create.name_required",
|
||||
"이름은 필수입니다.",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() {
|
||||
{/* 섹션 2: 권한 선택 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
|
||||
<h3 className="font-semibold text-lg">권한 범위(Scopes) 선택</h3>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||
2
|
||||
</span>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{t(
|
||||
"ui.admin.api_keys.create.section_scopes",
|
||||
"권한 범위(Scopes) 선택",
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
@@ -178,24 +307,39 @@ function ApiKeyCreatePage() {
|
||||
onClick={() => toggleScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-border bg-card hover:border-muted-foreground/30"
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-border bg-card hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-md flex items-center justify-center border",
|
||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold text-sm",
|
||||
isSelected ? "text-primary" : "",
|
||||
)}
|
||||
>
|
||||
{t(scope.labelKey, scope.labelFallback)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-md flex items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary border-primary"
|
||||
: "border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check size={12} className="text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{scope.desc}
|
||||
{t(scope.descKey, scope.descFallback)}
|
||||
</p>
|
||||
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
|
||||
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">
|
||||
ID: {scope.id}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -210,15 +354,26 @@ function ApiKeyCreatePage() {
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
|
||||
<div>
|
||||
<p className="text-sm font-bold">총 {selectedScopes.length}개의 권한이 할당됩니다.</p>
|
||||
<p className="text-xs text-muted-foreground">생성 즉시 활성화되어 사용 가능합니다.</p>
|
||||
<p className="text-sm font-bold">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scopes_count",
|
||||
"총 {{count}}개의 권한이 할당됩니다.",
|
||||
{ count: selectedScopes.length },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scopes_hint",
|
||||
"생성 즉시 활성화되어 사용 가능합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
size="lg"
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
size="lg"
|
||||
className="px-8 font-bold shadow-lg shadow-primary/20"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
@@ -227,7 +382,7 @@ function ApiKeyCreatePage() {
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
API 키 발급하기
|
||||
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyCreatePage;
|
||||
export default ApiKeyCreatePage;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const query = useQuery({
|
||||
@@ -37,30 +38,42 @@ function ApiKeyListPage() {
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
|
||||
!errorMsg && query.isError
|
||||
? t(
|
||||
"msg.admin.api_keys.list.fetch_error",
|
||||
"API 키 목록 조회에 실패했습니다.",
|
||||
)
|
||||
: null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.api_keys.list.delete_confirm",
|
||||
'API 키 "{{name}}"를 삭제할까요?',
|
||||
{ name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>API Keys</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">List</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">API 키 관리 (M2M)</h2>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고
|
||||
관리합니다.
|
||||
{t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -70,99 +83,128 @@ function ApiKeyListPage() {
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
API 키 생성
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>API Key Registry</CardTitle>
|
||||
<CardTitle>
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 API 키
|
||||
{t(
|
||||
"msg.admin.apikeys.registry.count",
|
||||
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
|
||||
{ count: query.data?.items?.length ?? 0 },
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">System</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>CLIENT ID</TableHead>
|
||||
<TableHead>SCOPES</TableHead>
|
||||
<TableHead>LAST USED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>등록된 API 키가 없습니다.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-[var(--color-muted)]" />
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.empty",
|
||||
"등록된 API 키가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key
|
||||
size={14}
|
||||
className="text-[var(--color-muted)]"
|
||||
/>
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
55
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
55
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ShieldHalf } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[AuthCallbackPage] State:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
error: auth.error,
|
||||
});
|
||||
if (auth.isAuthenticated) {
|
||||
// Save token to localStorage for existing API clients that might still use it
|
||||
const user = auth.user;
|
||||
if (user?.access_token) {
|
||||
window.localStorage.setItem("admin_session", user.access_token);
|
||||
}
|
||||
const returnTo =
|
||||
typeof auth.user?.state === "object" &&
|
||||
auth.user?.state !== null &&
|
||||
"returnTo" in auth.user.state &&
|
||||
typeof auth.user.state.returnTo === "string"
|
||||
? auth.user.state.returnTo
|
||||
: "/";
|
||||
console.info(
|
||||
"[AuthCallbackPage] Auth successful, navigating to",
|
||||
returnTo,
|
||||
);
|
||||
navigate(returnTo, { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("[AuthCallbackPage] Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">인증 완료 중...</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
세션을 동기화하고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthCallbackPage;
|
||||
150
adminfront/src/features/auth/LoginPage.tsx
Normal file
150
adminfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const returnTo = searchParams.get("returnTo") || "/";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[LoginPage] Auth state check:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
returnTo,
|
||||
});
|
||||
if (auth.isAuthenticated) {
|
||||
console.info(
|
||||
"[LoginPage] User is authenticated, redirecting to",
|
||||
returnTo,
|
||||
);
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoLogin) {
|
||||
return;
|
||||
}
|
||||
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Admin Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auth.error && (
|
||||
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
|
||||
<div className="font-bold flex items-center gap-2 mb-1">
|
||||
<ShieldHalf size={16} />
|
||||
인증 오류가 발생했습니다
|
||||
</div>
|
||||
<p className="opacity-90">{auth.error.message}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
window.location.href =
|
||||
window.location.origin + window.location.pathname;
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
관리자 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={auth.isLoading}
|
||||
>
|
||||
{auth.isLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,243 +0,0 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
LineChart,
|
||||
Radio,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
const guardHighlights = [
|
||||
{
|
||||
title: "Tenant isolation",
|
||||
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
|
||||
metric: "Header guard",
|
||||
accent: "active",
|
||||
},
|
||||
{
|
||||
title: "Admin TTL",
|
||||
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
|
||||
metric: "15m default",
|
||||
accent: "ttl",
|
||||
},
|
||||
{
|
||||
title: "Audit-first",
|
||||
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
|
||||
metric: "per-action",
|
||||
accent: "audit",
|
||||
},
|
||||
];
|
||||
|
||||
const stackReadiness = [
|
||||
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||
"TanStack Query 5 provider ready with sane defaults.",
|
||||
"Axios client stub with bearer + tenant header injector.",
|
||||
"Tailwind v4 tokens tuned for admin dark plane.",
|
||||
"React Hook Form + Zod planned for client forms.",
|
||||
"IdP-neutral auth hook point reserved for role guard.",
|
||||
];
|
||||
|
||||
const nextSteps = [
|
||||
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
|
||||
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
|
||||
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
|
||||
];
|
||||
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
<Sparkles size={14} />
|
||||
adminfront ready
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Build the admin plane with{" "}
|
||||
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
|
||||
defaults and{" "}
|
||||
<span className="text-[var(--color-accent-strong)]">
|
||||
least privilege
|
||||
</span>{" "}
|
||||
UX.
|
||||
</h2>
|
||||
<p className="text-[var(--color-muted)]">
|
||||
Route, query, and styling scaffolds are in place. Use this canvas
|
||||
to ship RP registry, audit exploration, and guarded login aligned
|
||||
with issue #60 while keeping providers swappable.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||
Router + Query wired
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||
Admin namespace only
|
||||
</span>
|
||||
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||
Auth hook pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
Admin guard scoped to /admin
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Building2 size={16} />
|
||||
Tenant selection placeholder ready
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Radio size={16} />
|
||||
Audit stream hook for ClickHouse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{guardHighlights.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{item.metric}
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||
{item.accent}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-3 space-y-2">
|
||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Stack readiness
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">Matches issue #60</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Setup notes
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{stackReadiness.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-[var(--color-accent)]"
|
||||
/>
|
||||
<p className="text-sm">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Next actions
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Ship the first guarded flows
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nextSteps.map((item, idx) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text)]">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Ops board
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">What to prototype next</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
Audit → ClickHouse
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
RP registry
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<LineChart size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">
|
||||
Metrics
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">
|
||||
RP registration funnel
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Visualize create → secret rotate → redirect URI edits per tenant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Activity size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Live feed of admin API calls with per-action tenant, actor, and
|
||||
rate-limit outcome.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">
|
||||
Access
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Outline SMS + app-based MFA choice and emphasize “admin session”
|
||||
TTL with logout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
Box,
|
||||
Database,
|
||||
Key,
|
||||
PlusCircle,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -16,153 +16,186 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
const summaryCards = [
|
||||
{
|
||||
label: "Total Tenants",
|
||||
value: "-",
|
||||
hint: "Tenant-aware core",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "OIDC Clients",
|
||||
value: "-",
|
||||
hint: "Hydra registry",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: "Audit Events (24h)",
|
||||
value: "-",
|
||||
hint: "ClickHouse stream",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: "Policy Gate",
|
||||
value: "Planned",
|
||||
hint: "Keto + Admin checks",
|
||||
icon: Database,
|
||||
},
|
||||
];
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Global Overview
|
||||
</p>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
Tenant-independent control plane
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="muted">IDP: Ory primary</Badge>
|
||||
<Badge variant="muted">Fallback: Descope</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
|
||||
<Card key={label} className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||
<Icon size={16} />
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{value}</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
활성화된 테넌트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
등록된 OIDC 앱
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RoleGuard>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Admin playbook</CardTitle>
|
||||
<CardDescription>
|
||||
운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.
|
||||
</CardDescription>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"최근 감사 로그 (24h)",
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
|
||||
<ShieldCheck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
Backend-only IDP access
|
||||
</p>
|
||||
<p>
|
||||
모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos
|
||||
admin 포트는 외부에 노출하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
Tenant isolation
|
||||
</p>
|
||||
<p>
|
||||
Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto
|
||||
정책으로 확장 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
발생한 이벤트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">빠른 이동</CardTitle>
|
||||
<CardDescription>
|
||||
주요 운영 화면으로 바로 이동합니다.
|
||||
</CardDescription>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
|
||||
<Database size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/tenants/new">
|
||||
테넌트 추가
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/audit-logs">
|
||||
감사 로그 보기
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/dashboard">
|
||||
테넌트 대시보드
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
|
||||
Active
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
접근 제어 정상 동작
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/tenants/new"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<PlusCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-primary">
|
||||
테넌트 추가
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
새로운 조직이나 그룹을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/users"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
|
||||
사용자 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
전체 사용자를 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/api-keys"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
|
||||
API 키 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
시스템 연동을 위한 키를 발급합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/audit-logs"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
|
||||
감사 로그
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
보안 이벤트를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<div className="pt-4">
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import apiClient from "../../../lib/apiClient";
|
||||
|
||||
type CheckPermissionResponse = {
|
||||
allowed: boolean;
|
||||
query: {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
subject: string;
|
||||
};
|
||||
};
|
||||
|
||||
function PermissionChecker() {
|
||||
const [namespace, setNamespace] = useState("Tenant");
|
||||
const [object, setObject] = useState("");
|
||||
const [relation, setRelation] = useState("manage");
|
||||
const [subject, setSubject] = useState("");
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||
"/v1/admin/debug/check-permission",
|
||||
{
|
||||
params: { namespace, object, relation, subject },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
ReBAC 권한 검증 도구
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
||||
Keto를 통해 실시간으로 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace</Label>
|
||||
<select
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="Tenant">Tenant</option>
|
||||
<option value="TenantGroup">TenantGroup</option>
|
||||
<option value="RelyingParty">RelyingParty</option>
|
||||
<option value="System">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Relation</Label>
|
||||
<Input
|
||||
placeholder="view, manage, admins..."
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Object ID</Label>
|
||||
<Input
|
||||
placeholder="Tenant UUID 등"
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject (User:ID)</Label>
|
||||
<Input
|
||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full md:w-auto px-12"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionChecker;
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type ImportResult,
|
||||
fetchImportProgress,
|
||||
importOrgChart,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface OrgChartUploadModalProps {
|
||||
tenantId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function OrgChartUploadModal({
|
||||
tenantId,
|
||||
onSuccess,
|
||||
}: OrgChartUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [result, setResult] = React.useState<ImportResult | null>(null);
|
||||
const [progressId, setProgressId] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
|
||||
importOrgChart(tenantId, file, pid),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setProgressId(null);
|
||||
if (data.errors.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.org.import_partial_success",
|
||||
"일부 데이터 업로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setProgressId(null);
|
||||
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: progressData } = useQuery({
|
||||
queryKey: ["importProgress", progressId],
|
||||
queryFn: () =>
|
||||
progressId ? fetchImportProgress(tenantId, progressId) : null,
|
||||
enabled: !!progressId && mutation.isPending,
|
||||
refetchInterval: 500,
|
||||
});
|
||||
const percent =
|
||||
progressData && progressData.total > 0
|
||||
? Math.round((progressData.current / progressData.total) * 100)
|
||||
: 0;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (file) {
|
||||
const pid = Math.random().toString(36).substring(2, 15);
|
||||
setProgressId(pid);
|
||||
mutation.mutate({ file, pid });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
|
||||
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
|
||||
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
|
||||
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "org_chart_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!result ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mutation.isPending && progressId && (
|
||||
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
|
||||
<span>데이터 처리 중...</span>
|
||||
<span>
|
||||
{percent}% ({progressData?.current || 0} /{" "}
|
||||
{progressData?.total || 0})
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
|
||||
style={{ width: `${Math.max(5, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
전체 행
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{result.totalRows}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
처리 완료
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.processed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
사용자 생성/업데이트
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{result.userCreated} / {result.userUpdated}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
조직(테넌트) 생성
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{result.tenantCreated}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertCircle size={16} />
|
||||
오류 목록 ({result.errors.length})
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
|
||||
key={idx}
|
||||
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
|
||||
>
|
||||
{" "}
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!result ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto relative"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
처리 중 ({percent}%)
|
||||
</>
|
||||
) : (
|
||||
t("ui.admin.org.start_import", "임포트 시작")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Crown,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
fetchTenantOwners,
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
removeTenantOwner,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const ownersQuery = useQuery({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
queryFn: () => fetchTenantOwners(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
queryFn: () => fetchTenantAdmins(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["admin-users-search", searchTerm],
|
||||
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||
enabled: dialogMode !== null && searchTerm.length >= 2,
|
||||
});
|
||||
|
||||
const addOwnerMutation = useMutation({
|
||||
mutationFn: (userId: string) => addTenantOwner(tenantId, userId),
|
||||
onMutate: async (userId) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
});
|
||||
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
|
||||
"tenant-owners",
|
||||
tenantId,
|
||||
]);
|
||||
|
||||
// Optimistically add to the list to prevent immediate double clicks
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (old.some((o) => o.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
return { previousOwners };
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Delay invalidation slightly to give the backend outbox time to process
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
|
||||
);
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
context.previousOwners,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeOwnerMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
|
||||
onMutate: async (userId) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
});
|
||||
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
|
||||
"tenant-owners",
|
||||
tenantId,
|
||||
]);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||
);
|
||||
return { previousOwners };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.owners.remove_success",
|
||||
"소유자 권한이 회수되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
context.previousOwners,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const addAdminMutation = useMutation({
|
||||
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
||||
onMutate: async (userId) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
|
||||
"tenant-admins",
|
||||
tenantId,
|
||||
]);
|
||||
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (old.some((a) => a.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
return { previousAdmins };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
|
||||
);
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
context.previousAdmins,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeAdminMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
||||
onMutate: async (userId) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
|
||||
"tenant-admins",
|
||||
tenantId,
|
||||
]);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||
);
|
||||
return { previousAdmins };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
context.previousAdmins,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddUser = (userId: string) => {
|
||||
if (dialogMode === "owner") {
|
||||
addOwnerMutation.mutate(userId);
|
||||
} else if (dialogMode === "admin") {
|
||||
addAdminMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOwner = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.owners.remove_confirm",
|
||||
"소유자를 삭제하시겠습니까?",
|
||||
{ name: userName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeOwnerMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.admins.remove_confirm",
|
||||
"관리자를 삭제하시겠습니까?",
|
||||
{ name: userName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeAdminMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const currentOwners = ownersQuery.data || [];
|
||||
const currentAdmins = adminsQuery.data || [];
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
const isDialogOpen = dialogMode !== null;
|
||||
|
||||
const dialogTitle =
|
||||
dialogMode === "owner"
|
||||
? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가")
|
||||
: t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가");
|
||||
|
||||
const dialogDescription =
|
||||
dialogMode === "owner"
|
||||
? t(
|
||||
"ui.admin.tenants.owners.dialog_description",
|
||||
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.dialog_description",
|
||||
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||
{/* Owners Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Crown className="h-6 w-6 text-yellow-500" />
|
||||
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.owners.subtitle",
|
||||
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.owners.empty",
|
||||
"등록된 소유자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{owner.name.charAt(0)}
|
||||
</div>
|
||||
<span>{owner.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admins Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.admins.subtitle",
|
||||
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.admins.empty",
|
||||
"등록된 관리자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{admin.name.charAt(0)}
|
||||
</div>
|
||||
<span>{admin.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||
{admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Common Dialog for adding users */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{dialogTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||
"사용자 검색 (최소 2자)...",
|
||||
)}
|
||||
className="pl-10 h-11"
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||
{searchTerm.length < 2 ? (
|
||||
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Search className="h-8 w-8 opacity-20" />
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_search_hint",
|
||||
"검색어를 입력해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : usersQuery.isLoading ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_no_results",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{searchResults.map((user) => {
|
||||
const isAlreadyOwner = currentOwners.some(
|
||||
(o) => o.id === user.id,
|
||||
);
|
||||
const isAlreadyAdmin = currentAdmins.some(
|
||||
(a) => a.id === user.id,
|
||||
);
|
||||
const isAlreadyMember =
|
||||
dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{user.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAlreadyMember ? "ghost" : "outline"}
|
||||
disabled={
|
||||
isAlreadyMember ||
|
||||
addOwnerMutation.isPending ||
|
||||
addAdminMutation.isPending
|
||||
}
|
||||
onClick={() => handleAddUser(user.id)}
|
||||
>
|
||||
{isAlreadyMember ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{dialogMode === "owner"
|
||||
? t(
|
||||
"ui.admin.tenants.owners.already_owner",
|
||||
"이미 소유자",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.already_admin",
|
||||
"이미 관리자",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||
{t("ui.common.add", "추가")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantAdminsAndOwnersTab;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -15,21 +15,31 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant } from "../../../lib/adminApi";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createTenant({
|
||||
name,
|
||||
type,
|
||||
slug: slug || undefined,
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
status,
|
||||
domains: domains
|
||||
@@ -48,19 +58,18 @@ function TenantCreatePage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Create</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 추가</h2>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.tenants.create.title", "테넌트 생성")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
글로벌 운영 기준의 신규 테넌트를 등록합니다.
|
||||
{t(
|
||||
"msg.admin.tenants.create.subtitle",
|
||||
"새로운 테넌트를 시스템에 등록합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Admin only</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -68,65 +77,159 @@ function TenantCreatePage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} />
|
||||
Tenant Profile
|
||||
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.
|
||||
{t(
|
||||
"msg.admin.tenants.create.profile.subtitle",
|
||||
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Tenant name <span className="text-destructive">*</span>
|
||||
<Label htmlFor="tenant-name" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
id="tenant-name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.name_placeholder",
|
||||
"테넌트 이름을 입력하세요",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-type" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="tenant-type"
|
||||
name="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{parentQuery.data?.items?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Slug</Label>
|
||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="tenant-slug"
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.slug_placeholder",
|
||||
"tenant-slug",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Description</Label>
|
||||
<Label
|
||||
htmlFor="tenant-description"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.create.form.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tenant-description"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Allowed Domains (Comma separated)
|
||||
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.create.form.domains_label",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-domains"
|
||||
name="domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
placeholder="example.com, example.kr"
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.domains_placeholder",
|
||||
"example.com, example.kr",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Users with these email domains will be automatically assigned to
|
||||
this tenant.
|
||||
{t(
|
||||
"msg.admin.tenants.create.form.domains_help",
|
||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Status</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
>
|
||||
Active
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
Inactive
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,26 +246,32 @@ function TenantCreatePage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles size={18} />
|
||||
정책 메모
|
||||
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.
|
||||
{t(
|
||||
"msg.admin.tenants.create.memo.subtitle",
|
||||
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||
생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.
|
||||
{t(
|
||||
"msg.admin.tenants.create.memo.body",
|
||||
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||
취소
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
생성
|
||||
{t("ui.common.create", "생성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,78 +2,100 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchTenant } from "../../../lib/adminApi";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const location = useLocation();
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link to="/tenants" className="inline-flex items-center gap-2">
|
||||
<ArrowLeft size={14} />
|
||||
Tenants
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Detail</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{tenantQuery.data?.name ?? "Loading Tenant..."}
|
||||
{tenantQuery.data?.name ??
|
||||
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Edit tenant information or manage federation settings.
|
||||
{t(
|
||||
"ui.admin.tenants.detail.header_subtitle",
|
||||
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Admin only</Badge>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<div className="flex border-b border-border">
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
!isFederationTab
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isOrganizationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Profile
|
||||
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/federation`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
isFederationTab
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
to={`/tenants/${tenantId}/permissions`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isPermissionsTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Federation
|
||||
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
location.pathname.includes("/schema")
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
to={`/tenants/${tenantId}/organization`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isOrganizationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Schema
|
||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||
</Link>
|
||||
{canAccessSchema && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
location.pathname.includes("/schema")
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
<Outlet />
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,6 +27,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,198 +37,584 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
fetchGroups,
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
isExpanded?: boolean;
|
||||
};
|
||||
|
||||
function buildGroupTree(
|
||||
groups: GroupSummary[],
|
||||
parentId: string | null = null,
|
||||
): UserGroupNode[] {
|
||||
const nodes: UserGroupNode[] = [];
|
||||
const childrenOf = new Map<string, UserGroupNode[]>();
|
||||
|
||||
// First pass: Initialize all groups as nodes and populate childrenOf map
|
||||
for (const group of groups) {
|
||||
childrenOf.set(group.id, []);
|
||||
}
|
||||
|
||||
// Second pass: Populate children
|
||||
for (const group of groups) {
|
||||
const node: UserGroupNode = {
|
||||
...group,
|
||||
children: childrenOf.get(group.id) ?? [],
|
||||
};
|
||||
if (group.parentId === parentId) {
|
||||
nodes.push(node);
|
||||
} else {
|
||||
// Check if the parent exists before adding to children
|
||||
// This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data)
|
||||
if (group.parentId && childrenOf.has(group.parentId)) {
|
||||
childrenOf.get(group.parentId)?.push(node);
|
||||
} else {
|
||||
// If parentId exists but parent not found, it's a root level group for this tree view
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children for consistent rendering (optional, but good for UI)
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const node of nodes) {
|
||||
node.children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface UserGroupTreeNodeProps {
|
||||
node: UserGroupNode;
|
||||
level: number;
|
||||
onSelect: (groupId: string) => void;
|
||||
selectedGroupId: string | null;
|
||||
onDelete: (groupId: string) => void;
|
||||
onAddSubGroup: (parentId: string) => void;
|
||||
addMemberMutation: UseMutationResult<
|
||||
void,
|
||||
AxiosError<{ error?: string }>,
|
||||
{ groupId: string; userId: string }
|
||||
>;
|
||||
removeMemberMutation: UseMutationResult<
|
||||
void,
|
||||
AxiosError<{ error?: string }>,
|
||||
{ groupId: string; userId: string }
|
||||
>;
|
||||
}
|
||||
|
||||
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
node,
|
||||
level,
|
||||
onSelect,
|
||||
selectedGroupId,
|
||||
onDelete,
|
||||
onAddSubGroup,
|
||||
addMemberMutation,
|
||||
removeMemberMutation,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
const handleToggleExpand = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChildren ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpand}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
level > 0 && (
|
||||
<span className="inline-block w-6 text-center">
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="text-muted-foreground inline-block align-middle"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
<span className="font-semibold">{node.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
{node.unitType || "Team"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{t("msg.admin.groups.members.count", "{{count}} 명", {
|
||||
count: node.members?.length || 0,
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubGroup(node.id);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded &&
|
||||
hasChildren &&
|
||||
node.children.map((child) => (
|
||||
<UserGroupTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onSelect={onSelect}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={onDelete}
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function TenantGroupsPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
||||
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
|
||||
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
|
||||
// 그룹 목록 조회
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["groups", tenantId],
|
||||
queryFn: () => fetchGroups(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
// 사용자 목록 조회 (멤버 추가용)
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { limit: 100 }],
|
||||
queryFn: () => fetchUsers(100, 0),
|
||||
queryFn: () => fetchGroups(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
// 그룹 생성
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
|
||||
mutationFn: () =>
|
||||
createGroup(tenantId, {
|
||||
name: newGroupName,
|
||||
description: newGroupDesc,
|
||||
unitType: newGroupUnitType,
|
||||
parentId: newGroupParentId || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.groups.list.create_success",
|
||||
"그룹이 성공적으로 생성되었습니다.",
|
||||
),
|
||||
);
|
||||
groupsQuery.refetch();
|
||||
setNewGroupName("");
|
||||
setNewGroupNameDesc("");
|
||||
setNewGroupUnitType("Team");
|
||||
setNewGroupParentId(null);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 그룹 삭제
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteGroup(id),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
mutationFn: (id: string) => deleteGroup(tenantId, id),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
|
||||
);
|
||||
groupsQuery.refetch();
|
||||
setSelectedGroupId(null);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 멤버 추가
|
||||
const addMemberMutation = useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
addGroupMember(tenantId, groupId, userId),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
|
||||
);
|
||||
groupsQuery.refetch();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.common.error", "오류 발생"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 멤버 제거
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
removeGroupMember(tenantId, groupId, userId),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
|
||||
);
|
||||
groupsQuery.refetch();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.common.error", "오류 발생"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const groupTree = groupsQuery.data
|
||||
? buildGroupTree(groupsQuery.data, tenantId)
|
||||
: [];
|
||||
|
||||
const handleAddSubGroup = (parentId: string) => {
|
||||
setNewGroupParentId(parentId);
|
||||
// Optionally scroll to the create form or highlight it
|
||||
};
|
||||
|
||||
const handleAddMember = (groupId: string) => {
|
||||
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
|
||||
const userId = window.prompt(
|
||||
t(
|
||||
"msg.admin.groups.prompt.user_id",
|
||||
"추가할 사용자의 UUID를 입력하세요:",
|
||||
),
|
||||
);
|
||||
if (userId) {
|
||||
addMemberMutation.mutate({ groupId, userId });
|
||||
}
|
||||
};
|
||||
|
||||
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
|
||||
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||
{/* 그룹 생성 폼 */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Plus size={16} /> 새 그룹 생성
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.create.description",
|
||||
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">그룹 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
placeholder="예: 개발팀, 인사팀"
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.name_placeholder",
|
||||
"예: 개발팀, 인사팀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desc">설명</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={e => setNewGroupNameDesc(e.target.value)}
|
||||
placeholder="그룹 용도 설명"
|
||||
<Label htmlFor="unitType">
|
||||
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
||||
</Label>
|
||||
<Input
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="parentId">
|
||||
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={newGroupParentId || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desc">
|
||||
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||
</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.desc_placeholder",
|
||||
"그룹 용도 설명",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
>
|
||||
생성하기
|
||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>User Groups</CardTitle>
|
||||
<CardDescription>이 테넌트에 정의된 사용자 그룹 목록입니다.</CardDescription>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.list.title", "User Groups")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.groups.list.subtitle",
|
||||
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgChartUploadModal
|
||||
tenantId={tenantId}
|
||||
onSuccess={() => groupsQuery.refetch()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => groupsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>MEMBERS</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<TableRow
|
||||
key={group.id}
|
||||
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
{group.name}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{group.description}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.members?.length || 0} 명</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
|
||||
<UserPlus size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||
{currentGroup && (
|
||||
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-primary" />
|
||||
[{currentGroup.name}] 멤버 관리
|
||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||
name: currentGroup.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.members_subtitle",
|
||||
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableHead className="text-right">제거</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground">멤버가 없습니다.</TableCell></TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddMember(currentGroup.id)}
|
||||
disabled={addMemberMutation.isPending}
|
||||
>
|
||||
<UserPlus size={14} className="mr-1" />
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"멤버가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +20,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,13 +30,46 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin") {
|
||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||
if (
|
||||
(manageableCount === 1 || manageableCount === 0) &&
|
||||
profile.tenantId
|
||||
) {
|
||||
navigate(`/tenants/${profile.tenantId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [profile, navigate]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["tenants", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchTenants(50, 0),
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
enabled:
|
||||
profile?.role === "super_admin" ||
|
||||
(profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) > 1),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -35,144 +79,335 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
setSelectedIds([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
profile.role !== "tenant_admin"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-xl font-bold">
|
||||
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) <= 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
|
||||
!errorMsg && query.isError
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenants = React.useMemo(() => {
|
||||
if (!search.trim()) return allTenants;
|
||||
const term = search.toLowerCase();
|
||||
return allTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}, [allTenants, search]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(tenants.map((t) => t.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.delete_bulk_confirm",
|
||||
"선택한 {{count}}개 테넌트를 삭제할까요?",
|
||||
{ count: selectedIds.length },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteBulkMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
const rootTenant =
|
||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.delete_confirm",
|
||||
'테넌트 "{{name}}"를 삭제할까요?',
|
||||
{ name: tenantName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(tenantId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">List</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 목록</h2>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.tenants.title", "테넌트 목록")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
현재 등록된 테넌트를 확인하고 상태를 관리합니다.
|
||||
{t(
|
||||
"msg.admin.tenants.subtitle",
|
||||
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteBulk}
|
||||
disabled={deleteBulkMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t("ui.admin.tenants.delete_selected", "선택 삭제")} (
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
</RoleGuard>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant?.id || "root"}
|
||||
onSuccess={() => query.refetch()}
|
||||
/>
|
||||
</RoleGuard>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
테넌트 추가
|
||||
</Link>
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>Tenant registry</CardTitle>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 테넌트
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
count: query.data?.total ?? 0,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Admin only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</CardHeader>{" "}
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>SLUG</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead>UPDATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
아직 등록된 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell>{tenant.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
className={
|
||||
tenant.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tenant.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
selectedIds.length === tenants.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectAll(!!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
편집
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant.id, !!checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -27,7 +30,9 @@ export function TenantProfilePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!tenantId) {
|
||||
return <div>Tenant ID is missing</div>;
|
||||
return (
|
||||
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
@@ -35,19 +40,31 @@ export function TenantProfilePage() {
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
});
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const availableParents =
|
||||
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
setName(tenantQuery.data.name);
|
||||
setType(tenantQuery.data.type || "COMPANY");
|
||||
setSlug(tenantQuery.data.slug);
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -55,9 +72,11 @@ export function TenantProfilePage() {
|
||||
mutationFn: () =>
|
||||
updateTenant(tenantId, {
|
||||
name,
|
||||
type,
|
||||
slug,
|
||||
description: description || undefined,
|
||||
status,
|
||||
parentId: parentId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
@@ -66,7 +85,13 @@ export function TenantProfilePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
alert("Tenant updated successfully");
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,7 +100,15 @@ export function TenantProfilePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
alert("Tenant approved successfully");
|
||||
toast.success(
|
||||
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,6 +116,9 @@ export function TenantProfilePage() {
|
||||
mutationFn: () => deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
navigate("/tenants");
|
||||
toast.success(
|
||||
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,13 +128,23 @@ export function TenantProfilePage() {
|
||||
?.response?.data?.error;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (window.confirm("Are you sure you want to delete this tenant?")) {
|
||||
if (
|
||||
window.confirm(
|
||||
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
|
||||
name: tenantQuery.data?.name ?? "",
|
||||
}),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
if (window.confirm("Approve this tenant?")) {
|
||||
if (
|
||||
window.confirm(
|
||||
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
|
||||
)
|
||||
) {
|
||||
approveMutation.mutate();
|
||||
}
|
||||
};
|
||||
@@ -107,9 +153,14 @@ export function TenantProfilePage() {
|
||||
<>
|
||||
<Card className="bg-[var(--color-panel)] mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Tenant profile</CardTitle>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Changes to slug and status are applied immediately.
|
||||
{t(
|
||||
"ui.admin.tenants.profile.subtitle",
|
||||
"슬러그 및 상태 변경은 즉시 적용됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -120,16 +171,79 @@ export function TenantProfilePage() {
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Tenant name <span className="text-destructive">*</span>
|
||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Slug</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
</option>
|
||||
<option value="COMPANY_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.company_group",
|
||||
"COMPANY_GROUP (그룹사/지주사)",
|
||||
)}
|
||||
</option>
|
||||
<option value="USER_GROUP">
|
||||
{t(
|
||||
"domain.tenant_type.user_group",
|
||||
"USER_GROUP (내부 부서/팀)",
|
||||
)}
|
||||
</option>
|
||||
<option value="PERSONAL">
|
||||
{t(
|
||||
"domain.tenant_type.personal",
|
||||
"PERSONAL (개인 워크스페이스)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
|
||||
{availableParents.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"ui.admin.tenants.profile.form.parent_help",
|
||||
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Description</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
@@ -138,7 +252,10 @@ export function TenantProfilePage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Allowed Domains (Comma separated)
|
||||
{t(
|
||||
"ui.admin.tenants.profile.allowed_domains",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={domains}
|
||||
@@ -146,26 +263,30 @@ export function TenantProfilePage() {
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Users with these email domains will be automatically assigned to
|
||||
this tenant.
|
||||
{t(
|
||||
"ui.admin.tenants.profile.allowed_domains_help",
|
||||
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Status</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
>
|
||||
Active
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
>
|
||||
Inactive
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +305,7 @@ export function TenantProfilePage() {
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{status === "pending" && (
|
||||
@@ -194,11 +315,11 @@ export function TenantProfilePage() {
|
||||
onClick={handleApprove}
|
||||
disabled={approveMutation.isPending}
|
||||
>
|
||||
Approve Tenant
|
||||
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||
Cancel
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
@@ -209,7 +330,7 @@ export function TenantProfilePage() {
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
Save
|
||||
{t("ui.common.save", "저장")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,53 +13,164 @@ import {
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "boolean";
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!tenantId) return <div>Tenant ID missing</div>;
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const canAccess =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
queryFn: () => {
|
||||
if (!tenantId) throw new Error("Tenant ID is required");
|
||||
return fetchTenant(tenantId);
|
||||
},
|
||||
enabled: !!tenantId && canAccess,
|
||||
});
|
||||
|
||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data?.config?.userSchema) {
|
||||
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
|
||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||
|
||||
if (Array.isArray(rawSchema)) {
|
||||
setFields(
|
||||
rawSchema.map((field) => ({
|
||||
id: typeof field?.id === "string" ? field.id : createFieldId(),
|
||||
key: typeof field?.key === "string" ? field.key : "",
|
||||
label: typeof field?.label === "string" ? field.label : "",
|
||||
type:
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date" ||
|
||||
field?.type === "float" ||
|
||||
field?.type === "datetime"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
isLoginId: Boolean(field?.isLoginId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (newFields: SchemaField[]) =>
|
||||
updateTenant(tenantId, {
|
||||
config: {
|
||||
...tenantQuery.data?.config,
|
||||
userSchema: newFields,
|
||||
},
|
||||
}),
|
||||
mutationFn: (newFields: SchemaField[]) => {
|
||||
if (!tenantId) throw new Error("Tenant ID is required");
|
||||
|
||||
// Remove legacy loginIdField, keep isLoginId natively in userSchema
|
||||
const newConfig = { ...tenantQuery.data?.config };
|
||||
newConfig.loginIdField = undefined;
|
||||
newConfig.userSchema = newFields;
|
||||
|
||||
return updateTenant(tenantId, {
|
||||
config: newConfig,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
alert("Schema updated successfully");
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.schema.update_success",
|
||||
"스키마가 저장되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
alert(err.response?.data?.error || "Failed to update schema");
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (isProfileLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.schema.forbidden_desc",
|
||||
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
|
||||
setFields([
|
||||
...fields,
|
||||
{
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
@@ -74,77 +185,239 @@ export function TenantSchemaPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>User Schema Extension</CardTitle>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Define custom attributes for users in this tenant.
|
||||
{t(
|
||||
"msg.admin.tenants.schema.subtitle",
|
||||
"이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addField} size="sm">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Field
|
||||
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-6">
|
||||
{fields.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
|
||||
No custom fields defined. Click "Add Field" to begin.
|
||||
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
|
||||
{t(
|
||||
"msg.admin.tenants.schema.empty",
|
||||
'정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Field Key (ID)</Label>
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) => updateField(index, { key: e.target.value })}
|
||||
placeholder="e.g. employee_id"
|
||||
/>
|
||||
<div
|
||||
key={field.id}
|
||||
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) =>
|
||||
updateField(index, { key: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.key_placeholder",
|
||||
"예: employee_id",
|
||||
)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) =>
|
||||
updateField(index, { label: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.label_placeholder",
|
||||
"예: 사번",
|
||||
)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.type", "유형")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date" ||
|
||||
nextType === "float" ||
|
||||
nextType === "datetime"
|
||||
) {
|
||||
updateField(index, {
|
||||
type: nextType as SchemaFieldType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="text">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_text",
|
||||
"텍스트 (Text)",
|
||||
)}
|
||||
</option>
|
||||
<option value="number">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_number",
|
||||
"숫자 (Integer)",
|
||||
)}
|
||||
</option>
|
||||
<option value="float">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_float",
|
||||
"실수 (Float)",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_boolean",
|
||||
"불리언 (Boolean)",
|
||||
)}
|
||||
</option>
|
||||
<option value="date">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_date",
|
||||
"날짜 (Date)",
|
||||
)}
|
||||
</option>
|
||||
<option value="datetime">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_datetime",
|
||||
"일시 (DateTime)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Display Label</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
placeholder="e.g. 사번"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) =>
|
||||
updateField(index, { required: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.adminOnly}
|
||||
onChange={(e) =>
|
||||
updateField(index, { adminOnly: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.admin_only",
|
||||
"관리자 전용",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
onChange={(e) =>
|
||||
updateField(index, { isLoginId: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.is_login_id",
|
||||
"로그인 ID로 사용",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.unsigned}
|
||||
onChange={(e) =>
|
||||
updateField(index, { unsigned: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.unsigned",
|
||||
"음수 불가",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={field.validation}
|
||||
onChange={(e) =>
|
||||
updateField(index, { validation: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.validation_placeholder",
|
||||
"정규식 (예: ^[0-9]+$)",
|
||||
)}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32 space-y-2">
|
||||
<Label>Type</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
value={field.type}
|
||||
onChange={(e) => updateField(index, { type: e.target.value as any })}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate(fields)}
|
||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
||||
className="px-8 h-11"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
Save Schema Changes
|
||||
<Save size={18} className="mr-2" />
|
||||
{t("ui.admin.tenants.schema.save", "변경사항 저장")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Plus, ArrowRight } from "lucide-react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -9,76 +18,112 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantSubTenantsPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data } = useQuery({
|
||||
queryKey: ["sub-tenants", tenantId],
|
||||
queryFn: () => fetchTenants(50, 0, tenantId),
|
||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const subTenants = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
Sub-tenants ({subTenants.length})
|
||||
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
|
||||
count: subTenants.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>현재 테넌트 하위에 생성된 조직입니다.</CardDescription>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.sub.subtitle",
|
||||
"현재 테넌트 하위에 생성된 조직입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`/tenants/new?parentId=${tenantId}`}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
하위 테넌트 추가
|
||||
</Link>
|
||||
<Link to={`/tenants/new?parentId=${tenantId}`}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>SLUG</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead className="text-right">ACTION</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
하위 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-semibold">{t.name}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={t.status === "active" ? "default" : "secondary"}>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
|
||||
관리 <ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.sub.empty",
|
||||
"하위 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
|
||||
import { Mail, User } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -9,80 +16,105 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
|
||||
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
|
||||
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const companyCode = tenantQuery.data?.slug;
|
||||
const tenantSlug = tenantQuery.data?.slug;
|
||||
|
||||
// 해당 슬러그로 사용자 검색
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { companyCode }],
|
||||
queryFn: () => fetchUsers(100, 0, companyCode),
|
||||
enabled: !!companyCode,
|
||||
queryKey: ["users", { tenantSlug }],
|
||||
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
Tenant Members ({users.length})
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
count: users.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>EMAIL</TableHead>
|
||||
<TableHead>ROLE</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
소속된 사용자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{user.role.replace("_", " ")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.status === "active" ? "default" : "muted"}>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.email", "EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.members.empty",
|
||||
"소속된 사용자가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
|
||||
if (isTenantsLoading)
|
||||
return <div className="p-8">Loading tenants and groups...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex items-start justify-between flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||
<p className="text-muted-foreground">
|
||||
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을
|
||||
설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 flex-1 overflow-auto p-1">
|
||||
{tenantList?.items.map((tenant) => (
|
||||
<TenantGroupCard key={tenant.id} tenant={tenant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["tenant-user-groups", tenant.id],
|
||||
queryFn: () => fetchGroups(tenant.id),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Building2 size={20} className="text-muted-foreground" />
|
||||
{tenant.name}
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{tenant.slug}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
이 테넌트에 정의된 유저 그룹 목록입니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/tenants/${tenant.id}/user-groups`}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
그룹 관리
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground py-4"
|
||||
>
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
>
|
||||
상세보기
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
Briefcase,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CornerDownRight,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
LayoutDashboard,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
UserCircle,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../../components/ui/tabs";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
createUser,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
updateTenant,
|
||||
updateUser,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
// --- Icons & Helpers ---
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
case "COMPANY_GROUP":
|
||||
return Briefcase;
|
||||
case "PERSONAL":
|
||||
return UserCircle;
|
||||
case "USER_GROUP":
|
||||
return Network;
|
||||
default:
|
||||
return Building2;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Components ---
|
||||
|
||||
const SidebarNode: React.FC<{
|
||||
node: TenantNode;
|
||||
level: number;
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
searchTerm: string;
|
||||
}> = ({ node, level, selectedId, onSelect, searchTerm }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isSelected = selectedId === node.id;
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
// Auto-expand on search
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
const matchInDescendants = (n: TenantNode): boolean => {
|
||||
return n.children.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
matchInDescendants(c),
|
||||
);
|
||||
};
|
||||
if (matchInDescendants(node)) setIsExpanded(true);
|
||||
}
|
||||
}, [searchTerm, node]);
|
||||
|
||||
const isMatching =
|
||||
searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center group px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground font-semibold"
|
||||
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
|
||||
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
|
||||
onClick={() => {
|
||||
onSelect(node.id);
|
||||
if (hasChildren) setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
{/* Indent & Expander */}
|
||||
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
|
||||
<div className="w-5 h-5 flex items-center justify-center mr-1">
|
||||
{hasChildren ? (
|
||||
<span className="rounded p-0.5">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TypeIcon
|
||||
size={16}
|
||||
className={`shrink-0 mr-2 ${isSelected ? "text-primary-foreground" : "text-primary/70"}`}
|
||||
/>
|
||||
<span className="text-sm truncate">{node.name}</span>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={isSelected ? "secondary" : "outline"}
|
||||
className={`ml-2 text-[10px] px-1 h-4 min-w-[1.5rem] justify-center ${
|
||||
isSelected ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
{node.recursiveMemberCount}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="flex flex-col">
|
||||
{node.children.map((child) => (
|
||||
<SidebarNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberTable: React.FC<{
|
||||
tenantSlug: string;
|
||||
onRefreshTrigger?: number;
|
||||
}> = ({ tenantSlug, onRefreshTrigger }) => {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
|
||||
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const members = data?.items ?? [];
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="py-20 text-center text-muted-foreground animate-pulse">
|
||||
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (members.length === 0)
|
||||
return (
|
||||
<div className="py-20 flex flex-col items-center justify-center text-muted-foreground opacity-50 border-2 border-dashed rounded-lg">
|
||||
<Users size={48} className="mb-4" />
|
||||
<p>
|
||||
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-[var(--color-panel)]">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead>{t("ui.admin.users.table.name", "이름")}</TableHead>
|
||||
<TableHead>{t("ui.admin.users.table.email", "이메일")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.table.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] font-bold uppercase"
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<ExternalLink size={14} className="mr-2" />
|
||||
{t("ui.common.detail", "상세보기")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
function TenantUserGroupsTab() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
|
||||
|
||||
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
|
||||
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
|
||||
const [existingSearch, setExistingSearch] = useState("");
|
||||
|
||||
// Data Fetching
|
||||
const {
|
||||
data: allTenantsData,
|
||||
isLoading: isTenantsLoading,
|
||||
refetch: refetchTree,
|
||||
} = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
const allItems = allTenantsData?.items ?? [];
|
||||
return buildTenantFullTree(allItems, tenantId);
|
||||
}, [allTenantsData, tenantId]);
|
||||
|
||||
const selectedNode = useMemo(() => {
|
||||
// Find selected node in the built tree
|
||||
const findNode = (nodes: TenantNode[], id: string): TenantNode | null => {
|
||||
if (!currentBase) return null;
|
||||
if (currentBase.id === id) return currentBase;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children.length > 0) {
|
||||
const found = findNode(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!currentBase) return null;
|
||||
return findNode(currentBase.children, selectedNodeId) || currentBase;
|
||||
}, [currentBase, selectedNodeId]);
|
||||
|
||||
// Mutations
|
||||
const updateParentMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
parentId,
|
||||
}: { id: string; parentId: string | undefined }) =>
|
||||
updateTenant(id, { parentId: parentId || "" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(
|
||||
t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."),
|
||||
);
|
||||
setIsAddExistingOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveNode = (id: string, name: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.remove_sub_confirm",
|
||||
`${name} 조직을 하위에서 제외할까요?`,
|
||||
{ name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
updateParentMutation.mutate({ id, parentId: undefined });
|
||||
if (selectedNodeId === id) setSelectedNodeId(tenantId || "");
|
||||
}
|
||||
};
|
||||
|
||||
if (isTenantsLoading)
|
||||
return (
|
||||
<div className="p-12 text-center text-muted-foreground animate-pulse">
|
||||
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
|
||||
</div>
|
||||
);
|
||||
if (!currentBase)
|
||||
return (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
테넌트를 찾을 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
|
||||
const candidates = (allTenantsData?.items ?? []).filter(
|
||||
(t) =>
|
||||
t.id !== tenantId &&
|
||||
t.parentId !== tenantId &&
|
||||
(existingSearch === "" ||
|
||||
t.name.toLowerCase().includes(existingSearch.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(existingSearch.toLowerCase())),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-theme(spacing.32))] gap-6 mt-6 overflow-hidden">
|
||||
{/* --- Left Panel: Sidebar Tree --- */}
|
||||
<Card className="w-80 flex flex-col shrink-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<Network size={16} className="text-primary" />
|
||||
조직도
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => refetchTree()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "조직 검색...")}
|
||||
className="pl-8 h-8 text-xs bg-muted/40"
|
||||
value={treeSearch}
|
||||
onChange={(e) => setTreeSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-2">
|
||||
<ScrollArea className="h-full pr-2">
|
||||
<SidebarNode
|
||||
node={currentBase}
|
||||
level={0}
|
||||
selectedId={selectedNodeId}
|
||||
onSelect={setSelectedNodeId}
|
||||
searchTerm={treeSearch}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<div className="p-3 border-t bg-muted/5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs h-8"
|
||||
onClick={() => setIsAddExistingOpen(true)}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* --- Right Panel: Selected Node Content --- */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{selectedNode ? (
|
||||
<Card className="flex-1 flex flex-col border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/5 py-4 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
{React.createElement(getTenantIcon(selectedNode.type), {
|
||||
size: 24,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{selectedNode.name}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono opacity-60"
|
||||
>
|
||||
{selectedNode.slug}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
|
||||
{t("ui.admin.tenants.table.members", "명")}
|
||||
</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4">
|
||||
{t(
|
||||
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
|
||||
selectedNode.type,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsUserAddOpen(true)}
|
||||
>
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
{t("ui.admin.users.list.add", "멤버 추가")}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{t("ui.common.manage", "조직 관리")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/tenants/${selectedNode.id}`}>
|
||||
<LayoutDashboard size={14} className="mr-2" />
|
||||
상세 프로필로 이동
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!selectedNode.parentId && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/tenants/new?parentId=${selectedNode.id}`}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{selectedNode.id !== tenantId && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveNode(selectedNode.id, selectedNode.name)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("ui.common.remove", "조직 계층에서 제외")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-8">
|
||||
{selectedNode.children.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold flex items-center gap-2">
|
||||
<Network size={16} className="text-muted-foreground" />
|
||||
하위 조직 ({selectedNode.children.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{selectedNode.children?.map((child) => (
|
||||
<Card
|
||||
key={child.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors bg-muted/20"
|
||||
onClick={() => setSelectedNodeId(child.id)}
|
||||
>
|
||||
<CardHeader className="p-4 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-1.5 rounded bg-background">
|
||||
{React.createElement(
|
||||
getTenantIcon(child.type),
|
||||
{
|
||||
size: 14,
|
||||
className: "text-primary",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px]">
|
||||
{child.recursiveMemberCount}{" "}
|
||||
{t("ui.admin.tenants.table.members", "명")}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-sm">
|
||||
{child.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px] truncate">
|
||||
{child.slug}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold flex items-center gap-2">
|
||||
<Users size={16} className="text-muted-foreground" />
|
||||
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
|
||||
</h3>
|
||||
<MemberTable
|
||||
tenantSlug={selectedNode.slug}
|
||||
onRefreshTrigger={refreshMembersCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground opacity-30 border-2 border-dashed rounded-lg">
|
||||
<div className="text-center">
|
||||
<FolderOpen size={64} className="mx-auto mb-4" />
|
||||
<p>조직을 선택해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Dialogs --- */}
|
||||
<UserAddDialog
|
||||
tenantSlug={selectedNode?.slug || ""}
|
||||
tenantName={selectedNode?.name || ""}
|
||||
open={isUserAddOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsUserAddOpen(open);
|
||||
if (!open) setRefreshMembersCount((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={isAddExistingOpen} onOpenChange={setIsAddExistingOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
기존에 생성된 테넌트를 [{currentBase.name}] 하위로 가져옵니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
className="pl-9"
|
||||
value={existingSearch}
|
||||
onChange={(e) => setExistingSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-60 border rounded-md">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{candidates?.map((tenantItem) => (
|
||||
<TableRow
|
||||
key={tenantItem.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() =>
|
||||
updateParentMutation.mutate({
|
||||
id: tenantItem.id,
|
||||
parentId: tenantId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{React.createElement(getTenantIcon(tenantItem.type), {
|
||||
size: 14,
|
||||
className: "text-muted-foreground",
|
||||
})}
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{tenantItem.name}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground">
|
||||
{tenantItem.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<Plus size={14} className="mr-1" />{" "}
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Internal Support Components ---
|
||||
|
||||
const UserAddDialog: React.FC<{
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ tenantSlug, tenantName, open, onOpenChange }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!userSearch) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetchUsers(20, 0, userSearch);
|
||||
setSearchResults(res.items);
|
||||
} catch (err) {
|
||||
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser(selectedUserId, { tenantSlug });
|
||||
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
|
||||
onOpenChange(false);
|
||||
resetFields();
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error?: string }>;
|
||||
toast.error(
|
||||
error.response?.data?.error ||
|
||||
t("msg.admin.users.detail.update_error", "배정 실패"),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFields = () => {
|
||||
setUserSearch("");
|
||||
setSearchResults([]);
|
||||
setSelectedUserId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
if (!v) resetFields();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.create.title", "멤버 추가")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
[{tenantName}] 조직에 기존 사용자를 배정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이메일 검색...",
|
||||
)}
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-60 border rounded-md">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{searchResults?.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
{selectedUserId === user.id && (
|
||||
<ChevronRight size={16} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={isSubmitting || !selectedUserId}
|
||||
>
|
||||
{t("ui.common.add", "배정")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantUserGroupsTab;
|
||||
@@ -0,0 +1,620 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addGroupMember,
|
||||
assignGroupRole,
|
||||
fetchGroup,
|
||||
fetchGroupRoles,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
removeGroupMember,
|
||||
removeGroupRole,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function UserGroupDetailPage() {
|
||||
const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [searchUser, setSearchUser] = useState("");
|
||||
|
||||
const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
|
||||
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
|
||||
const [selectedRelation, setSelectedRelation] = useState("view");
|
||||
|
||||
const {
|
||||
data: currentGroup,
|
||||
isLoading: isGroupLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["user-group-detail", id],
|
||||
queryFn: () => fetchGroup(tenantId ?? "", id ?? ""),
|
||||
enabled: !!id && !!tenantId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch assigned roles
|
||||
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
|
||||
queryKey: ["user-group-roles", id],
|
||||
queryFn: () => fetchGroupRoles(tenantId ?? "", id ?? ""),
|
||||
enabled: !!id && !!tenantId,
|
||||
});
|
||||
|
||||
// Fetch all users for selection
|
||||
const { data: userList } = useQuery({
|
||||
queryKey: ["admin-users", searchUser],
|
||||
queryFn: () => fetchUsers(20, 0, searchUser),
|
||||
enabled: isAddMemberOpen,
|
||||
});
|
||||
|
||||
// Fetch all tenants for role assignment
|
||||
const { data: tenantList } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
enabled: isAddRoleOpen,
|
||||
});
|
||||
|
||||
const addMemberMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
addGroupMember(tenantId ?? "", id ?? "", userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
|
||||
setIsAddMemberOpen(false);
|
||||
setSelectedUserId("");
|
||||
toast.success(
|
||||
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
removeGroupMember(tenantId ?? "", id ?? "", userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_success",
|
||||
"구성원이 제외되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const assignRoleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
assignGroupRole(
|
||||
tenantId ?? "",
|
||||
id ?? "",
|
||||
selectedTargetTenantId,
|
||||
selectedRelation,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
||||
setIsAddRoleOpen(false);
|
||||
toast.success(
|
||||
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeRoleMutation = useMutation({
|
||||
mutationFn: (role: { targetTenantId: string; relation: string }) =>
|
||||
removeGroupRole(
|
||||
tenantId ?? "",
|
||||
id ?? "",
|
||||
role.targetTenantId,
|
||||
role.relation,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
||||
toast.success(
|
||||
t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (isGroupLoading)
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !currentGroup)
|
||||
return (
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<h3 className="text-xl font-semibold text-destructive">
|
||||
조직 단위를 불러올 수 없습니다
|
||||
</h3>
|
||||
<div className="p-4 bg-destructive/10 text-destructive rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-destructive/20">
|
||||
<p>
|
||||
Error:{" "}
|
||||
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
"Not found"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
{t("ui.common.retry", "다시 시도")}
|
||||
</Button>
|
||||
<div className="pt-4 border-t">
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/organization`}
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.groups.detail.breadcrumb_org",
|
||||
"조직 관리 목록으로 돌아가기",
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t("ui.admin.groups.detail.breadcrumb_tenant", "테넌트 상세")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/organization`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">
|
||||
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Users size={24} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
|
||||
{currentGroup.unitType && (
|
||||
<Badge variant="secondary" className="h-6 font-normal">
|
||||
{currentGroup.unitType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentGroup.description ||
|
||||
t("msg.common.no_description", "설명이 없습니다.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||
{/* Members Management */}
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.members_subtitle",
|
||||
"이 조직에 소속된 사용자를 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.members_subtitle",
|
||||
"사용자를 검색하여 조직 구성원으로 추가합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.common.search", "사용자 검색")}</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이메일 또는 이름으로 검색...",
|
||||
)}
|
||||
value={searchUser}
|
||||
onChange={(e) => setSearchUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("ui.common.select", "사용자 선택")}</Label>
|
||||
<Select
|
||||
value={selectedUserId}
|
||||
onValueChange={setSelectedUserId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.common.select_placeholder",
|
||||
"사용자를 선택하세요",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userList?.items.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddMemberOpen(false)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addMemberMutation.mutate(selectedUserId)}
|
||||
disabled={!selectedUserId || addMemberMutation.isPending}
|
||||
>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members ||
|
||||
currentGroup.members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"구성원이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"제거하시겠습니까?",
|
||||
{ name: member.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles/Permissions Management (Keto Based) */}
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.groups.detail.permissions_subtitle",
|
||||
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Shield size={16} className="mr-2" />
|
||||
{t("ui.common.assign", "할당")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.groups.detail.permissions_title",
|
||||
"테넌트 역할 할당",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.groups.roles.description",
|
||||
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedTargetTenantId}
|
||||
onValueChange={setSelectedTargetTenantId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.select_placeholder",
|
||||
"테넌트를 선택하세요",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenantList?.items.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.admin.users.detail.form.role", "역할 (Relation)")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRelation}
|
||||
onValueChange={setSelectedRelation}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="view">View (조회 권한)</SelectItem>
|
||||
<SelectItem value="manage">
|
||||
Manage (운영 권한)
|
||||
</SelectItem>
|
||||
<SelectItem value="admins">
|
||||
Admin (모든 권한)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddRoleOpen(false)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => assignRoleMutation.mutate()}
|
||||
disabled={
|
||||
!selectedTargetTenantId || assignRoleMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("ui.common.assign", "할당")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.roles.empty",
|
||||
"할당된 역할이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{role.tenantName || role.tenantId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize font-normal"
|
||||
>
|
||||
{role.relation}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t("msg.admin.groups.roles.remove_confirm"),
|
||||
)
|
||||
) {
|
||||
removeRoleMutation.mutate({
|
||||
targetTenantId: role.tenantId,
|
||||
relation: role.relation,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,18 +15,34 @@ import {
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
createUser,
|
||||
fetchTenants,
|
||||
fetchTenant,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createUser,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
|
||||
function UserCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
|
||||
@@ -36,34 +52,78 @@ function UserCreatePage() {
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
role: "user",
|
||||
companyCode: "",
|
||||
tenantSlug: "",
|
||||
department: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCompanyCode = watch("companyCode");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
setValue("tenantSlug", profile.tenantSlug);
|
||||
}
|
||||
}, [profile, setValue]);
|
||||
|
||||
const selectedTenantSlug = watch("tenantSlug");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
|
||||
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
const { data: tenantDetail } = useQuery({
|
||||
queryKey: ["tenant", selectedTenant?.id],
|
||||
queryFn: () => fetchTenant(selectedTenant!.id),
|
||||
enabled: !!selectedTenant?.id,
|
||||
queryKey: ["tenant", selectedTenantId],
|
||||
queryFn: () => fetchTenant(selectedTenantId),
|
||||
enabled: selectedTenantId.length > 0,
|
||||
});
|
||||
|
||||
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
|
||||
const userSchema: UserSchemaField[] = Array.isArray(
|
||||
tenantDetail?.config?.userSchema,
|
||||
)
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
const registerMetadata = (field: UserSchemaField) =>
|
||||
register(`metadata.${field.key}` as `metadata.${string}`, {
|
||||
required: field.required
|
||||
? t(
|
||||
"msg.admin.users.create.form.field_required",
|
||||
"{{label}}은(는) 필수입니다.",
|
||||
{
|
||||
label: field.label || field.key,
|
||||
},
|
||||
)
|
||||
: false,
|
||||
pattern: field.validation
|
||||
? {
|
||||
value: new RegExp(field.validation),
|
||||
message: t(
|
||||
"msg.admin.users.create.form.field_invalid",
|
||||
"{{label}} 형식이 올바르지 않습니다.",
|
||||
{ label: field.label || field.key },
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
@@ -77,26 +137,33 @@ function UserCreatePage() {
|
||||
navigate("/users");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: UserCreateRequest) => {
|
||||
const onSubmit = (data: UserFormValues) => {
|
||||
setError(null);
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
const payload = { ...data };
|
||||
|
||||
if (autoPassword) {
|
||||
mutation.mutate({ ...data, password: "" });
|
||||
payload.password = "";
|
||||
} else if (!data.password) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.password_required",
|
||||
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.password) {
|
||||
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(data);
|
||||
mutation.mutate(payload);
|
||||
};
|
||||
|
||||
const onCopyPassword = async () => {
|
||||
@@ -112,19 +179,14 @@ function UserCreatePage() {
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link to="/users" className="hover:underline">
|
||||
Users
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">New</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.users.create.title", "사용자 추가")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/users">
|
||||
<ArrowLeft size={16} className="mr-2" />
|
||||
목록으로 돌아가기
|
||||
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
@@ -132,9 +194,23 @@ function UserCreatePage() {
|
||||
{generatedPassword && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
||||
<CardTitle>
|
||||
{t(
|
||||
"ui.admin.users.create.password_generated.title",
|
||||
"초기 비밀번호 생성 완료",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
|
||||
{createdEmail
|
||||
? t(
|
||||
"msg.admin.users.create.password_generated.with_email",
|
||||
"{{email}} 계정의 초기 비밀번호입니다.",
|
||||
{ email: createdEmail },
|
||||
)
|
||||
: t(
|
||||
"msg.admin.users.create.password_generated.default",
|
||||
"초기 비밀번호가 생성되었습니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -142,11 +218,13 @@ function UserCreatePage() {
|
||||
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
{t("ui.common.copy", "복사")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => navigate("/users")}>목록으로 이동</Button>
|
||||
<Button onClick={() => navigate("/users")}>
|
||||
{t("ui.admin.users.create.go_list", "목록으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,8 +232,15 @@ function UserCreatePage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>계정 정보</CardTitle>
|
||||
<CardDescription>새로운 사용자를 시스템에 등록합니다.</CardDescription>
|
||||
<CardTitle>
|
||||
{t("ui.admin.users.create.account.title", "계정 정보")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.create.account.subtitle",
|
||||
"새로운 사용자를 시스템에 등록합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
@@ -166,182 +251,274 @@ function UserCreatePage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Label htmlFor="email">
|
||||
{t("ui.admin.users.create.form.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="user@example.com"
|
||||
{...register("email", { required: "이메일은 필수입니다." })}
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.email_placeholder",
|
||||
"user@example.com",
|
||||
)}
|
||||
{...register("email", {
|
||||
required: t(
|
||||
"msg.admin.users.create.form.email_required",
|
||||
"이메일은 필수입니다.",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email.message}</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<Label htmlFor="password">
|
||||
{t("ui.admin.users.create.form.password", "비밀번호")}
|
||||
</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPassword}
|
||||
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||
/>
|
||||
자동 생성
|
||||
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.password_placeholder",
|
||||
"********",
|
||||
)}
|
||||
disabled={autoPassword}
|
||||
{...register("password")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{autoPassword
|
||||
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
|
||||
: "초기 비밀번호를 직접 설정합니다."}
|
||||
? t(
|
||||
"msg.admin.users.create.form.password_auto_help",
|
||||
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.admin.users.create.form.password_manual_help",
|
||||
"초기 비밀번호를 직접 설정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름</Label>
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.users.create.form.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.name_placeholder",
|
||||
"홍길동",
|
||||
)}
|
||||
{...register("name", {
|
||||
required: t(
|
||||
"msg.admin.users.create.form.name_required",
|
||||
"이름은 필수입니다.",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호</Label>
|
||||
<Label htmlFor="phone">
|
||||
{t("ui.admin.users.create.form.phone", "전화번호")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-1234-5678"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.phone_placeholder",
|
||||
"010-1234-5678",
|
||||
)}
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenantSlug">
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
||||
</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<select
|
||||
id="tenantSlug"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.users.create.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</option>
|
||||
|
||||
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">
|
||||
{t("ui.admin.users.create.form.department", "부서")}
|
||||
</Label>
|
||||
|
||||
<select
|
||||
<Input
|
||||
id="department"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.department_placeholder",
|
||||
"개발팀",
|
||||
)}
|
||||
{...register("department")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
id="companyCode"
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">
|
||||
{t("ui.admin.users.create.form.position", "직급")}
|
||||
</Label>
|
||||
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<Input
|
||||
id="position"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.position_placeholder",
|
||||
"수석/책임/선임",
|
||||
)}
|
||||
{...register("position")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{...register("companyCode")}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobTitle">
|
||||
{t("ui.admin.users.create.form.job_title", "직무")}
|
||||
</Label>
|
||||
|
||||
>
|
||||
<Input
|
||||
id="jobTitle"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.job_title_placeholder",
|
||||
"프론트엔드 개발",
|
||||
)}
|
||||
{...register("jobTitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<option value="">시스템 전역 (소속 없음)</option>
|
||||
|
||||
{tenants.map((t) => (
|
||||
|
||||
<option key={t.id} value={t.slug}>
|
||||
|
||||
{t.name} ({t.slug})
|
||||
|
||||
</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
<Label htmlFor="department">부서</Label>
|
||||
|
||||
<Input
|
||||
|
||||
id="department"
|
||||
|
||||
placeholder="개발팀"
|
||||
|
||||
{...register("department")}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{userSchema.length > 0 && (
|
||||
|
||||
<div className="border-t pt-4">
|
||||
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||
|
||||
테넌트 확장 정보 (Custom Fields)
|
||||
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
{userSchema.map((field) => (
|
||||
|
||||
<div key={field.key} className="space-y-2">
|
||||
|
||||
<Label htmlFor={`metadata.${field.key}`}>
|
||||
|
||||
{field.label}
|
||||
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
|
||||
id={`metadata.${field.key}`}
|
||||
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
|
||||
{...register(`metadata.${field.key}` as any)}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{userSchema.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.users.create.custom_fields.title",
|
||||
"테넌트 확장 정보 (Custom Fields)",
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{userSchema.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`metadata.${field.key}`}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
{field.adminOnly && (
|
||||
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
|
||||
Admin Only
|
||||
</span>
|
||||
)}
|
||||
{field.isLoginId && (
|
||||
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{t(
|
||||
"ui.admin.users.create.form.is_login_id",
|
||||
"로그인 ID",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
|
||||
<Input
|
||||
id={`metadata.${field.key}`}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean" ? "w-auto h-auto" : ""
|
||||
}
|
||||
{...registerMetadata(field)}
|
||||
/>
|
||||
{errors.metadata?.[field.key] && (
|
||||
<p className="text-xs text-destructive">
|
||||
{
|
||||
(errors.metadata[field.key] as { message?: string })
|
||||
?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">역할 (Role)</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">
|
||||
{t("ui.admin.users.create.form.role", "역할 (Role)")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">
|
||||
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||
</option>
|
||||
<option value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
||||
</option>
|
||||
<option value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
||||
</option>
|
||||
<option value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
시스템 접근 권한을 결정합니다.
|
||||
{t(
|
||||
"msg.admin.users.create.form.role_help",
|
||||
"시스템 접근 권한을 결정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -351,14 +528,14 @@ function UserCreatePage() {
|
||||
variant="outline"
|
||||
onClick={() => navigate("/users")}
|
||||
>
|
||||
취소
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
사용자 생성
|
||||
{t("ui.admin.users.create.submit", "사용자 생성")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,12 @@ import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@@ -21,6 +23,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
@@ -30,19 +41,99 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
exportUsersCSVUrl,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
const limit = 50;
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
setSelectedCompany(profile.tenantSlug);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const selectedTenantId = React.useMemo(() => {
|
||||
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
|
||||
}, [tenants, selectedCompany]);
|
||||
|
||||
const { data: tenantDetail } = useQuery({
|
||||
queryKey: ["tenant", selectedTenantId],
|
||||
queryFn: () => fetchTenant(selectedTenantId),
|
||||
enabled: selectedTenantId.length > 0,
|
||||
});
|
||||
|
||||
const userSchema: UserSchemaField[] = Array.isArray(
|
||||
tenantDetail?.config?.userSchema,
|
||||
)
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
// Initialize visible columns when schema changes
|
||||
React.useEffect(() => {
|
||||
if (userSchema.length > 0) {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const field of userSchema) {
|
||||
initial[field.key] = true;
|
||||
}
|
||||
setVisibleColumns((prev) => {
|
||||
// Only set if not already set for these keys to avoid reset on every render
|
||||
const next = { ...initial, ...prev };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [userSchema]);
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
setVisibleColumns((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search }],
|
||||
queryFn: () => fetchUsers(limit, offset, search),
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
@@ -64,40 +155,115 @@ function UserListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = exportUsersCSVUrl(search, selectedCompany);
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
|
||||
!errorMsg && query.isError
|
||||
? t(
|
||||
"msg.admin.users.list.fetch_error",
|
||||
"사용자 목록 조회에 실패했습니다.",
|
||||
)
|
||||
: null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (items.length > 0) {
|
||||
console.log("User items:", items);
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedUserIds.length === items.length) {
|
||||
setSelectedUserIds([]);
|
||||
} else {
|
||||
setSelectedUserIds(items.map((u) => u.id));
|
||||
}
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
const toggleSelectUser = (id: string) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: bulkDeleteUsers,
|
||||
onSuccess: (_, variables) => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_success",
|
||||
"{{count}}명의 사용자가 삭제되었습니다.",
|
||||
{ count: variables.length },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const bulkUpdateMutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.update_success",
|
||||
"선택한 사용자들의 정보가 수정되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkStatusChange = (status: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_confirm",
|
||||
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
|
||||
{ count: selectedUserIds.length },
|
||||
),
|
||||
)
|
||||
) {
|
||||
bulkDeleteMutation.mutate(selectedUserIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.users.list.delete_confirm",
|
||||
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
|
||||
{ name: userName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(userId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Users</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">List</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 관리</h2>
|
||||
<h2 className="text-3xl font-semibold" data-testid="page-title">
|
||||
{t("ui.admin.users.list.title", "사용자 관리")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
시스템 사용자를 조회하고 관리합니다. (Local DB)
|
||||
{t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -107,152 +273,396 @@ function UserListPage() {
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExport} className="gap-2">
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export", "내보내기")}
|
||||
</Button>
|
||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.list.columns.description",
|
||||
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{userSchema.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t(
|
||||
"msg.admin.users.list.columns.no_custom",
|
||||
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{userSchema.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
onChange={() => toggleColumn(field.key)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{field.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{field.key}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild>
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
사용자 추가
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>User Registry</CardTitle>
|
||||
<CardTitle>
|
||||
{t("ui.admin.users.list.registry.title", "User Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {total}명의 사용자가 등록되어 있습니다.
|
||||
{t(
|
||||
"msg.admin.users.list.registry.count",
|
||||
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||
{ count: total },
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="이름 또는 이메일 검색..."
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
|
||||
</span>
|
||||
<select
|
||||
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
검색
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME / EMAIL</TableHead>
|
||||
<TableHead>ROLE</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead>TENANT / DEPT</TableHead>
|
||||
<TableHead>CREATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
</span>
|
||||
{user.tenant && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase">
|
||||
Slug: {user.tenant.slug}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
aria-label={`사용자 수정: ${user.name}`}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={`사용자 삭제: ${user.name}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"NAME / EMAIL",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.users.list.empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.tenantSlug || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={
|
||||
deleteMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedUserIds.length > 0 && (
|
||||
<div
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
|
||||
data-testid="bulk-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
|
||||
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
|
||||
count: selectedUserIds.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("active")}
|
||||
data-testid="bulk-active-btn"
|
||||
>
|
||||
{t("ui.common.status.active", "활성화")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("inactive")}
|
||||
data-testid="bulk-inactive-btn"
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
selectedUsers={items.filter((u) =>
|
||||
selectedUserIds.includes(u.id),
|
||||
)}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
}}
|
||||
/>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||
onClick={handleBulkDelete}
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
||||
onClick={() => setSelectedUserIds([])}
|
||||
aria-label={t("ui.common.close", "닫기")}
|
||||
data-testid="bulk-close-btn"
|
||||
>
|
||||
<Plus size={16} className="rotate-45" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -260,10 +670,13 @@ function UserListPage() {
|
||||
disabled={page === 1 || query.isFetching}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
||||
page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -271,7 +684,7 @@ function UserListPage() {
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || query.isFetching}
|
||||
>
|
||||
Next
|
||||
{t("ui.common.next", "Next")}
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
interface UserBulkMoveGroupModalProps {
|
||||
userIds: string[];
|
||||
selectedUsers?: UserSummary[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkMoveGroupModal({
|
||||
userIds,
|
||||
selectedUsers = [],
|
||||
onSuccess,
|
||||
}: UserBulkMoveGroupModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedTenantSlug, setSelectedTenantSlug] =
|
||||
React.useState<string>("");
|
||||
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
enabled: open,
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
const selectedTenant = React.useMemo(
|
||||
() => tenants.find((t) => t.slug === selectedTenantSlug),
|
||||
[tenants, selectedTenantSlug],
|
||||
);
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||
queryKey: ["tenant-groups", selectedTenantId],
|
||||
queryFn: () => fetchGroups(selectedTenantId),
|
||||
enabled: open && !!selectedTenantId,
|
||||
});
|
||||
|
||||
const schemaWarnings = React.useMemo(() => {
|
||||
if (!selectedTenant || selectedUsers.length === 0) return null;
|
||||
|
||||
const targetSchema =
|
||||
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
|
||||
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
|
||||
const requiredKeys = targetSchema
|
||||
.filter((f) => f.required)
|
||||
.map((f) => f.key);
|
||||
|
||||
const missingRequiredFields = new Set<string>();
|
||||
const incompatibleFields = new Set<string>();
|
||||
|
||||
for (const user of selectedUsers) {
|
||||
const userMeta = user.metadata || {};
|
||||
|
||||
// 1. Check for missing required fields
|
||||
for (const key of requiredKeys) {
|
||||
if (
|
||||
userMeta[key] === undefined ||
|
||||
userMeta[key] === null ||
|
||||
userMeta[key] === ""
|
||||
) {
|
||||
missingRequiredFields.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
|
||||
for (const key of Object.keys(userMeta)) {
|
||||
if (!targetSchemaKeys.has(key)) {
|
||||
incompatibleFields.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
missing: Array.from(missingRequiredFields),
|
||||
incompatible: Array.from(incompatibleFields),
|
||||
};
|
||||
}, [selectedTenant, selectedUsers]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.move_success",
|
||||
"사용자들의 부서가 이동되었습니다.",
|
||||
),
|
||||
);
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMove = () => {
|
||||
if (!selectedTenantSlug) return;
|
||||
mutation.mutate({
|
||||
userIds,
|
||||
tenantSlug: selectedTenantSlug,
|
||||
department: selectedGroupName, // can be empty for "No Department"
|
||||
});
|
||||
};
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
if (!groups) return [];
|
||||
if (!searchTerm) return groups;
|
||||
return groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [groups, searchTerm]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setSelectedTenantSlug("");
|
||||
setSelectedGroupName("");
|
||||
setAcknowledgeWarning(false);
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
>
|
||||
{t("ui.admin.users.bulk.move_group", "부서 이동")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.bulk.move_description",
|
||||
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
|
||||
{ count: userIds.length },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedTenantSlug}
|
||||
onChange={(e) => {
|
||||
setSelectedTenantSlug(e.target.value);
|
||||
setSelectedGroupName("");
|
||||
setAcknowledgeWarning(false);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedTenantSlug && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("ui.admin.users.bulk.select_group", "부서 선택")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "검색...")}
|
||||
className="pl-9 h-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[200px] rounded-md border p-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedGroupName("")}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||
selectedGroupName === ""
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<FolderTree size={14} />
|
||||
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
|
||||
</button>
|
||||
{isGroupsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedGroupName(group.name)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||
selectedGroupName === group.name
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<FolderTree size={14} />
|
||||
{group.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schemaWarnings && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-destructive font-semibold">
|
||||
<AlertTriangle size={16} />
|
||||
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
|
||||
</div>
|
||||
<div className="text-destructive/80 text-xs">
|
||||
{schemaWarnings.missing.length > 0 && (
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.users.bulk.schema_missing",
|
||||
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
|
||||
)}{" "}
|
||||
<strong>{schemaWarnings.missing.join(", ")}</strong>
|
||||
</p>
|
||||
)}
|
||||
{schemaWarnings.incompatible.length > 0 && (
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.users.bulk.schema_incompatible",
|
||||
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
|
||||
)}{" "}
|
||||
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledgeWarning}
|
||||
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
|
||||
/>
|
||||
<span className="font-medium text-destructive/90">
|
||||
{t(
|
||||
"ui.admin.users.bulk.acknowledge_warning",
|
||||
"경고를 확인했으며 계속 진행합니다.",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
disabled={
|
||||
!selectedTenantSlug ||
|
||||
mutation.isPending ||
|
||||
(!!schemaWarnings && !acknowledgeWarning)
|
||||
}
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
302
adminfront/src/features/users/components/UserBulkUploadModal.tsx
Normal file
302
adminfront/src/features/users/components/UserBulkUploadModal.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
type BulkUserItem,
|
||||
type BulkUserResult,
|
||||
bulkCreateUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [parsing, setParsing] = React.useState(false);
|
||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkCreateUsers,
|
||||
onSuccess: (data) => {
|
||||
setResults(data.results);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
parseCSV(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (file: File) => {
|
||||
setParsing(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const data = parseUserCSV(text);
|
||||
setPreviewData(data);
|
||||
setParsing(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (previewData.length > 0) {
|
||||
mutation.mutate(previewData);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers =
|
||||
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
setResults(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const successCount = results?.filter((r) => r.success).length ?? 0;
|
||||
const failCount = results ? results.length - successCount : 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
data-testid="bulk-import-btn"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-testid="bulk-upload-title">
|
||||
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.bulk.description",
|
||||
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!results ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
{parsing ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("msg.common.parsing", "파싱 중...")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.bulk.parsed_count",
|
||||
"{{count}}명의 사용자가 감지되었습니다.",
|
||||
{ count: previewData.length },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.length > 0 && (
|
||||
<ScrollArea className="h-[200px] rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="p-2 text-left">Email</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">Tenant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u) => (
|
||||
<tr key={u.email} className="border-t">
|
||||
<td className="p-2">{u.email}</td>
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{previewData.length > 10 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="p-2 text-center text-muted-foreground italic"
|
||||
>
|
||||
... and {previewData.length - 10} more users
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{failCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.fail", "실패")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[250px] rounded-md border">
|
||||
<div className="p-2 space-y-2">
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.email}
|
||||
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
||||
>
|
||||
{r.success ? (
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-green-500 mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-destructive mt-0.5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!results ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={previewData.length === 0 || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-close-dialog-btn"
|
||||
>
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
46
adminfront/src/features/users/utils/csvParser.test.ts
Normal file
46
adminfront/src/features/users/utils/csvParser.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUserCSV } from "./csvParser";
|
||||
|
||||
describe("parseUserCSV", () => {
|
||||
it("should parse valid CSV correctly", () => {
|
||||
const csv = `email,name,phone,role,tenant,department,emp_id
|
||||
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
|
||||
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
email: "user1@test.com",
|
||||
name: "Hong Gil Dong",
|
||||
phone: "010-1111-2222",
|
||||
role: "user",
|
||||
tenantSlug: "baron",
|
||||
department: "HR",
|
||||
metadata: {
|
||||
emp_id: "E001",
|
||||
},
|
||||
});
|
||||
expect(result[1].email).toBe("user2@test.com");
|
||||
expect(result[1].metadata.emp_id).toBe("E002");
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
expect(parseUserCSV("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip rows without email or name", () => {
|
||||
const csv = `email,name
|
||||
,Only Name
|
||||
no-name@test.com,`;
|
||||
expect(parseUserCSV(csv)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle mixed case headers", () => {
|
||||
const csv = `EMAIL,Name,Tenant
|
||||
test@test.com,Test,baron`;
|
||||
const result = parseUserCSV(csv);
|
||||
expect(result[0].email).toBe("test@test.com");
|
||||
expect(result[0].tenantSlug).toBe("baron");
|
||||
});
|
||||
});
|
||||
52
adminfront/src/features/users/utils/csvParser.ts
Normal file
52
adminfront/src/features/users/utils/csvParser.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { BulkUserItem } from "../../../lib/adminApi";
|
||||
|
||||
export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const data: BulkUserItem[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
const values = lines[i].split(",").map((v) => v.trim());
|
||||
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
for (let index = 0; index < headers.length; index++) {
|
||||
const header = headers[index];
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
} else if (header === "phone") {
|
||||
item.phone = value;
|
||||
} else if (header === "role") {
|
||||
item.role = value;
|
||||
} else if (header === "tenant") {
|
||||
item.tenantSlug = value;
|
||||
} else if (header === "department") {
|
||||
item.department = value;
|
||||
} else if (header === "position") {
|
||||
item.position = value;
|
||||
} else if (header === "jobtitle") {
|
||||
item.jobTitle = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.email && item.name) {
|
||||
data.push(item as BulkUserItem);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -21,23 +21,28 @@ export type AuditLogListResponse = {
|
||||
|
||||
export type TenantSummary = {
|
||||
id: string;
|
||||
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
status: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, any>;
|
||||
parentId?: string;
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // Added member count
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TenantCreateRequest = {
|
||||
name: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, any>;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TenantListResponse = {
|
||||
@@ -49,11 +54,13 @@ export type TenantListResponse = {
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, any>;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ApiKeySummary = {
|
||||
@@ -92,11 +99,11 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenants(limit = 50, offset = 0) {
|
||||
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset },
|
||||
params: { limit, offset, parentId },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
@@ -132,6 +139,12 @@ export async function deleteTenant(tenantId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||
}
|
||||
|
||||
export async function deleteTenantsBulk(ids: string[]) {
|
||||
await apiClient.delete("/v1/admin/tenants/bulk", {
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveTenant(tenantId: string) {
|
||||
const { data } = await apiClient.post<TenantSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/approve`,
|
||||
@@ -139,6 +152,194 @@ export async function approveTenant(tenantId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export type TenantAdmin = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export async function fetchTenantAdmins(tenantId: string) {
|
||||
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||
`/v1/admin/tenants/${tenantId}/admins`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addTenantAdmin(tenantId: string, userId: string) {
|
||||
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||
}
|
||||
|
||||
export async function removeTenantAdmin(tenantId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchTenantOwners(tenantId: string) {
|
||||
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||
`/v1/admin/tenants/${tenantId}/owners`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addTenantOwner(tenantId: string, userId: string) {
|
||||
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||
}
|
||||
|
||||
export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||
}
|
||||
|
||||
// Group Management
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type GroupSummary = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
unitType?: string;
|
||||
members?: GroupMember[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type GroupCreateRequest = {
|
||||
name: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
unitType?: string;
|
||||
};
|
||||
|
||||
export async function fetchGroups(tenantId: string) {
|
||||
const { data } = await apiClient.get<GroupSummary[]>(
|
||||
`/v1/admin/tenants/${tenantId}/organization`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGroup(tenantId: string, groupId: string) {
|
||||
const { data } = await apiClient.get<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createGroup(
|
||||
tenantId: string,
|
||||
payload: GroupCreateRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/organization`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteGroup(tenantId: string, groupId: string) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function addGroupMember(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
|
||||
{ userId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeGroupMember(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
totalRows: number;
|
||||
processed: number;
|
||||
userCreated: number;
|
||||
userUpdated: number;
|
||||
tenantCreated: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export async function fetchImportProgress(
|
||||
tenantId: string,
|
||||
progressId: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<{ current: number; total: number }>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importOrgChart(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
progressId?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const url = progressId
|
||||
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
|
||||
: `/v1/admin/tenants/${tenantId}/organization/import`;
|
||||
|
||||
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export type GroupRole = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
relation: string;
|
||||
};
|
||||
|
||||
export async function fetchGroupRoles(tenantId: string, groupId: string) {
|
||||
const { data } = await apiClient.get<GroupRole[]>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function assignGroupRole(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
targetTenantId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
|
||||
{ tenantId: targetTenantId, relation },
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeGroupRole(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
targetTenantId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
|
||||
);
|
||||
}
|
||||
|
||||
// API Key Management (M2M)
|
||||
export type ApiKeyCreateRequest = {
|
||||
name: string;
|
||||
@@ -176,14 +377,19 @@ export async function deleteApiKey(apiKeyId: string) {
|
||||
export type UserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
tenantSlug?: string;
|
||||
companyCode?: string;
|
||||
tenant?: TenantSummary;
|
||||
metadata?: Record<string, any>;
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -197,12 +403,16 @@ export type UserListResponse = {
|
||||
|
||||
export type UserCreateRequest = {
|
||||
email: string;
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type UserCreateResponse = UserSummary & {
|
||||
@@ -210,18 +420,51 @@ export type UserCreateResponse = UserSummary & {
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
|
||||
export type BulkUserItem = {
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
email: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
results: BulkUserResult[];
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
search?: string,
|
||||
tenantSlug?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search },
|
||||
params: { limit, offset, search, tenantSlug },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -234,17 +477,102 @@ export async function fetchUser(userId: string) {
|
||||
}
|
||||
|
||||
export async function createUser(payload: UserCreateRequest) {
|
||||
// Map tenantSlug to companyCode for backend compatibility
|
||||
const requestPayload: UserCreateRequest & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<UserCreateResponse>(
|
||||
"/v1/admin/users",
|
||||
payload,
|
||||
requestPayload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append("search", search);
|
||||
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
||||
|
||||
// Get mock role from storage if exists for dev environment
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
|
||||
|
||||
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
||||
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
const mappedUsers = users.map((u) => {
|
||||
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
|
||||
if (u.tenantSlug !== undefined) {
|
||||
mapped.companyCode = u.tenantSlug;
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
const { data } = await apiClient.post<BulkUserResponse>(
|
||||
"/v1/admin/users/bulk",
|
||||
{ users: mappedUsers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkUpdateUsers(payload: {
|
||||
userIds: string[];
|
||||
status?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
}) {
|
||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkDeleteUsers(userIds: string[]) {
|
||||
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
|
||||
data: { userIds },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
payload,
|
||||
requestPayload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type PasswordPolicyResponse = {
|
||||
minLength?: number;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
number?: boolean;
|
||||
nonAlphanumeric?: boolean;
|
||||
minCharacterTypes?: number;
|
||||
};
|
||||
|
||||
export async function fetchPasswordPolicy() {
|
||||
const { data } = await apiClient.get<PasswordPolicyResponse>(
|
||||
"/v1/auth/password/policy",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -253,6 +581,40 @@ export async function deleteUser(userId: string) {
|
||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export type UserRpHistoryItem = {
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
lastLoginAt: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export async function fetchUserRpHistory(userId: string) {
|
||||
const { data } = await apiClient.get<UserRpHistoryItem[]>(
|
||||
`/v1/admin/users/${userId}/rp-history`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type UserProfileResponse = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
department: string;
|
||||
affiliationType: string;
|
||||
tenantSlug?: string;
|
||||
tenantId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
tenant?: TenantSummary;
|
||||
manageableTenants?: TenantSummary[];
|
||||
};
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
// Relying Party Management
|
||||
export type RelyingParty = {
|
||||
clientId: string;
|
||||
@@ -272,7 +634,7 @@ export type HydraClientReq = {
|
||||
token_endpoint_auth_method?: string;
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function fetchRelyingParties(tenantId: string) {
|
||||
@@ -319,3 +681,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||
export async function deleteRelyingParty(id: string) {
|
||||
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||
}
|
||||
|
||||
export type RPOwner = {
|
||||
subject: string;
|
||||
|
||||
name?: string;
|
||||
|
||||
email?: string;
|
||||
|
||||
type: string;
|
||||
};
|
||||
|
||||
export async function fetchRPOwners(clientId: string) {
|
||||
const { data } = await apiClient.get<RPOwner[]>(
|
||||
`/v1/admin/relying-parties/${clientId}/owners`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addRPOwner(clientId: string, subject: string) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeRPOwner(clientId: string, subject: string) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import axios from "axios";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
|
||||
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE
|
||||
? "http://playwright-mock/api"
|
||||
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||
const sessionToken = window.localStorage.getItem("admin_session");
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
// IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다.
|
||||
const user = await userManager.getUser();
|
||||
const sessionToken =
|
||||
user?.access_token || window.localStorage.getItem("admin_session");
|
||||
|
||||
if (sessionToken) {
|
||||
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||
}
|
||||
|
||||
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||
// 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||
if (tenantId) {
|
||||
config.headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
|
||||
// [Development Only] Inject Mock Role from RoleSwitcher
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (mockRole) {
|
||||
if (isMockRoleEnabled && mockRole) {
|
||||
config.headers["X-Test-Role"] = mockRole;
|
||||
}
|
||||
|
||||
@@ -28,8 +37,30 @@ apiClient.interceptors.request.use((config) => {
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.warn(
|
||||
"[apiClient] 401 Unauthorized detected. Clearing session state.",
|
||||
);
|
||||
|
||||
// 로컬 스토리지의 세션 키 제거
|
||||
window.localStorage.removeItem("admin_session");
|
||||
|
||||
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
|
||||
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
||||
await userManager.removeUser();
|
||||
|
||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
||||
const isLoginPath = window.location.pathname === "/login";
|
||||
|
||||
if (!isAuthPath && !isLoginPath) {
|
||||
console.info(
|
||||
"[apiClient] Redirecting to /login from",
|
||||
window.location.pathname,
|
||||
);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
22
adminfront/src/lib/auth.ts
Normal file
22
adminfront/src/lib/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
|
||||
export const oidcConfig: AuthProviderProps = {
|
||||
authority:
|
||||
import.meta.env.VITE_OIDC_AUTHORITY || `${window.location.protocol}//${window.location.hostname}:{{USERFRONT_PORT}}/oidc`,
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
|
||||
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
automaticSilentRenew: false,
|
||||
};
|
||||
|
||||
export const userManager = new UserManager({
|
||||
...oidcConfig,
|
||||
authority: oidcConfig.authority || "",
|
||||
client_id: oidcConfig.client_id || "",
|
||||
redirect_uri: oidcConfig.redirect_uri || "",
|
||||
});
|
||||
37
adminfront/src/lib/i18n.test.ts
Normal file
37
adminfront/src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { t } from "./i18n";
|
||||
|
||||
describe("i18n utility", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns fallback if key not found", () => {
|
||||
expect(t("this.key.truly.does.not.exist", "Fallback")).toBe("Fallback");
|
||||
});
|
||||
|
||||
it("returns key if fallback not provided and key not found", () => {
|
||||
expect(t("this.key.truly.does.not.exist")).toBe(
|
||||
"this.key.truly.does.not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces variables in template", () => {
|
||||
expect(t("this.test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects locale in localStorage", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
// We expect some key that exists in en.toml
|
||||
// Let's use a common one or a fallback if we don't know the content
|
||||
expect(t("ui.common.save", "Save")).toBe("Save");
|
||||
});
|
||||
|
||||
it("defaults to ko if no locale set and browser language is ko", () => {
|
||||
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
|
||||
expect(t("ui.common.save", "저장")).toBe("저장");
|
||||
});
|
||||
});
|
||||
148
adminfront/src/lib/i18n.ts
Normal file
148
adminfront/src/lib/i18n.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const DEFAULT_LOCALE = "ko";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
type TomlValue = string | TomlObject;
|
||||
|
||||
interface TomlObject {
|
||||
[key: string]: TomlValue;
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function parseToml(raw: string): TomlObject {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const root: TomlObject = {};
|
||||
let currentPath: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
const sectionName = line.slice(1, -1).trim();
|
||||
currentPath = sectionName
|
||||
? sectionName
|
||||
.split(".")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = line.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, eqIndex).trim();
|
||||
const valueRaw = line.slice(eqIndex + 1).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = valueRaw;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
let cursor: TomlObject = root;
|
||||
for (const section of currentPath) {
|
||||
if (!cursor[section] || typeof cursor[section] === "string") {
|
||||
cursor[section] = {};
|
||||
}
|
||||
cursor = cursor[section] as TomlObject;
|
||||
}
|
||||
|
||||
cursor[key] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function getValue(target: TomlObject, key: string): string | undefined {
|
||||
const parts = key.split(".");
|
||||
let cursor: TomlValue = target;
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = (cursor as TomlObject)[part];
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : undefined;
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored && isSupportedLocale(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale && isSupportedLocale(pathLocale)) {
|
||||
return pathLocale;
|
||||
}
|
||||
|
||||
const browserLang = window.navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith("ko")) {
|
||||
return "ko";
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import enRaw from "../locales/en.toml?raw";
|
||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import koRaw from "../locales/ko.toml?raw";
|
||||
|
||||
const translations: Record<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined || value === null) {
|
||||
return match;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function t(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const locale = detectLocale();
|
||||
const value = getValue(translations[locale], key);
|
||||
if (value && value.length > 0) {
|
||||
return formatTemplate(value, vars);
|
||||
}
|
||||
return formatTemplate(fallback ?? key, vars);
|
||||
}
|
||||
126
adminfront/src/lib/sessionSliding.test.ts
Normal file
126
adminfront/src/lib/sessionSliding.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SESSION_RENEW_THRESHOLD_MS,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "./sessionSliding";
|
||||
|
||||
describe("shouldAttemptSlidingSessionRenew", () => {
|
||||
const nowMs = 1_700_000_000_000;
|
||||
|
||||
it("returns false when remaining time is above the 5 minute threshold", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when remaining time is within the 5 minute threshold", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when automatic renewal is disabled", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: false,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the last renew attempt is still within the throttle window", () => {
|
||||
expect(
|
||||
shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: nowMs - 10_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAttemptUnlimitedSessionRenew", () => {
|
||||
const nowMs = 1_700_000_000_000;
|
||||
|
||||
it("returns false when unlimited mode is not active", () => {
|
||||
expect(
|
||||
shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: true,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true near expiry when session expiry management is disabled", () => {
|
||||
expect(
|
||||
shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: false,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the token still has enough remaining lifetime", () => {
|
||||
expect(
|
||||
shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec: Math.floor(
|
||||
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
|
||||
),
|
||||
nowMs,
|
||||
isEnabled: false,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isRenewInFlight: false,
|
||||
lastAttemptAtMs: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
106
adminfront/src/lib/sessionSliding.ts
Normal file
106
adminfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||
|
||||
type SlidingSessionRenewDecisionParams = {
|
||||
expiresAtSec?: number | null;
|
||||
nowMs: number;
|
||||
isEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRenewInFlight: boolean;
|
||||
lastAttemptAtMs: number;
|
||||
thresholdMs?: number;
|
||||
throttleMs?: number;
|
||||
};
|
||||
|
||||
export function shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
isEnabled,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isRenewInFlight,
|
||||
lastAttemptAtMs,
|
||||
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||
}: SlidingSessionRenewDecisionParams) {
|
||||
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof expiresAtSec !== "number") {
|
||||
console.debug(
|
||||
"[sessionSliding] expiresAtSec is not a number, skipping renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
const remainingMin = Math.floor(remainingMs / 1000 / 60);
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
console.debug("[sessionSliding] Session already expired, skipping renew");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
console.debug("[sessionSliding] Throttling renewal attempt");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
isEnabled,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isRenewInFlight,
|
||||
lastAttemptAtMs,
|
||||
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||
}: SlidingSessionRenewDecisionParams) {
|
||||
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof expiresAtSec !== "number") {
|
||||
console.debug(
|
||||
"[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
const remainingMin = Math.floor(remainingMs / 1000 / 60);
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
console.debug(
|
||||
"[sessionSliding] Session already expired, skipping unlimited renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
93
adminfront/src/lib/tenantTree.test.ts
Normal file
93
adminfront/src/lib/tenantTree.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
import { buildTenantFullTree } from "./tenantTree";
|
||||
|
||||
describe("tenantTree utility", () => {
|
||||
const mockTenants: TenantSummary[] = [
|
||||
{
|
||||
id: "root-1",
|
||||
name: "Root",
|
||||
slug: "root",
|
||||
type: "COMPANY",
|
||||
memberCount: 10,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child 1",
|
||||
slug: "child-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 5,
|
||||
parentId: "root-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "grandchild-1",
|
||||
name: "Grandchild 1",
|
||||
slug: "grandchild-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 2,
|
||||
parentId: "child-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
it("calculates recursive member counts correctly", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "root-1");
|
||||
|
||||
expect(currentBase).not.toBeNull();
|
||||
if (currentBase) {
|
||||
// Direct: 10, Child: 5, Grandchild: 2 -> Total: 17
|
||||
expect(currentBase.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase.children).toHaveLength(1);
|
||||
|
||||
const child = currentBase.children[0];
|
||||
// Direct: 5, Grandchild: 2 -> Total: 7
|
||||
expect(child.recursiveMemberCount).toBe(7);
|
||||
expect(child.children).toHaveLength(1);
|
||||
|
||||
const grandchild = child.children[0];
|
||||
// Direct: 2 -> Total: 2
|
||||
expect(grandchild.recursiveMemberCount).toBe(2);
|
||||
expect(grandchild.children).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null currentBase if rootId is not found", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
|
||||
expect(currentBase).toBeNull();
|
||||
});
|
||||
|
||||
it("builds correct structure with multiple roots", () => {
|
||||
const multiRootTenants: TenantSummary[] = [
|
||||
...mockTenants,
|
||||
{
|
||||
id: "root-2",
|
||||
name: "Root 2",
|
||||
slug: "root-2",
|
||||
type: "COMPANY",
|
||||
memberCount: 3,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
const { subTree } = buildTenantFullTree(multiRootTenants);
|
||||
expect(subTree).toHaveLength(2);
|
||||
expect(subTree.map((n) => n.id)).toContain("root-1");
|
||||
expect(subTree.map((n) => n.id)).toContain("root-2");
|
||||
});
|
||||
});
|
||||
87
adminfront/src/lib/tenantTree.ts
Normal file
87
adminfront/src/lib/tenantTree.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
|
||||
export type TenantNode = TenantSummary & {
|
||||
children: TenantNode[];
|
||||
recursiveMemberCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree from a flat list of tenants and calculates
|
||||
* direct and recursive member counts for each node.
|
||||
*/
|
||||
export function buildTenantFullTree(
|
||||
allTenants: TenantSummary[],
|
||||
rootId?: string,
|
||||
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||
|
||||
const tenantMap = new Map<string, TenantNode>();
|
||||
for (const t of allTenants) {
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: t.memberCount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const visitedDuringBuild = new Set<string>();
|
||||
// Build initial children relations and prevent simple cycles
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId && t.parentId !== t.id) {
|
||||
const parent = tenantMap.get(t.parentId);
|
||||
const child = tenantMap.get(t.id);
|
||||
if (parent && child) {
|
||||
// Simple cycle prevention during build: don't add if it creates an immediate loop
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visitedForCalc = new Set<string>();
|
||||
// Function to calculate recursive counts with cycle protection
|
||||
const calculateRecursive = (node: TenantNode): number => {
|
||||
if (visitedForCalc.has(node.id)) {
|
||||
console.warn(
|
||||
`Circular dependency detected in tenant tree for ID: ${node.id}`,
|
||||
);
|
||||
return 0; // Prevent infinite loop
|
||||
}
|
||||
visitedForCalc.add(node.id);
|
||||
|
||||
let total = node.memberCount || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
|
||||
// We don't remove from visitedForCalc here because a tree shouldn't have
|
||||
// multiple paths to the same node anyway (it's a tree, not a graph).
|
||||
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
||||
// a node should only be visited once.
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If a specific rootId is provided, find and return its subtree
|
||||
if (rootId) {
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
}
|
||||
return { currentBase: null, subTree: [] };
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
return { currentBase: null, subTree: roots };
|
||||
}
|
||||
13
adminfront/src/lib/utils.test.ts
Normal file
13
adminfront/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cn } from "./utils";
|
||||
|
||||
describe("cn utility", () => {
|
||||
it("merges class names correctly", () => {
|
||||
expect(cn("a", "b")).toBe("a b");
|
||||
expect(cn("a", { b: true, c: false })).toBe("a b");
|
||||
});
|
||||
|
||||
it("handles tailwind class conflicts", () => {
|
||||
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
|
||||
});
|
||||
});
|
||||
@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function generateSecurePassword(length = 16): string {
|
||||
const charset =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
|
||||
let password = "";
|
||||
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
||||
const values = new Uint32Array(length);
|
||||
crypto.getRandomValues(values);
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset[values[i] % charset.length];
|
||||
}
|
||||
} else {
|
||||
// Fallback for older environments
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
1709
adminfront/src/locales/en.toml
Normal file
1709
adminfront/src/locales/en.toml
Normal file
File diff suppressed because it is too large
Load Diff
1707
adminfront/src/locales/ko.toml
Normal file
1707
adminfront/src/locales/ko.toml
Normal file
File diff suppressed because it is too large
Load Diff
1708
adminfront/src/locales/template.toml
Normal file
1708
adminfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
@@ -14,8 +17,16 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
8
adminfront/src/test/setup.ts
Normal file
8
adminfront/src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
114
adminfront/tests/auth.spec.ts
Normal file
114
adminfront/tests/auth.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 1. Force state
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
// 2. High-priority Mocks
|
||||
await page.route("**/api/v1/user/me", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes(".well-known/openid-configuration")) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||
end_session_endpoint: "http://localhost:5000/oidc/session/end",
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else if (url.includes("/jwks")) {
|
||||
await route.fulfill({
|
||||
json: { keys: [] },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: "ok",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Catch-all for others
|
||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({ json: { items: [], total: 0 } });
|
||||
} else {
|
||||
await route.fulfill({ status: 200, json: {} });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should redirect unauthorized users to login page", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.clear();
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/.*\/login.*/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test("should allow access to dashboard when authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
// strict mode violation 피하기 위해 .last() 사용하거나 더 구체적인 셀렉터 사용
|
||||
await expect(page.locator("h1").last()).toContainText(
|
||||
/Admin Control|운영 도구/i,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("should logout and redirect to login page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
const logoutBtn = page
|
||||
.locator("button")
|
||||
.filter({ hasText: /Logout|로그아웃/i })
|
||||
.first();
|
||||
await logoutBtn.waitFor({ state: "visible" });
|
||||
await logoutBtn.click();
|
||||
await expect(page).toHaveURL(/.*\/login.*/, { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
183
adminfront/tests/bulk_actions.spec.ts
Normal file
183
adminfront/tests/bulk_actions.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Bulk Actions and Tree Search", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin", role: "super_admin", name: "Admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
// Capture ALL API calls to our mock host
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.includes("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin",
|
||||
role: "super_admin",
|
||||
name: "Admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/users")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "u-1",
|
||||
name: "User One",
|
||||
email: "u1@test.com",
|
||||
status: "active",
|
||||
role: "user",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "u-2",
|
||||
name: "User Two",
|
||||
email: "u2@test.com",
|
||||
status: "active",
|
||||
role: "user",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/organization")) {
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" },
|
||||
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" },
|
||||
],
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/tenants/t-1")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "t-1",
|
||||
name: "Main Tenant",
|
||||
slug: "main",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/tenants")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{ id: "t-1", name: "Main Tenant", slug: "main", type: "COMPANY" },
|
||||
{
|
||||
id: "g-1",
|
||||
name: "Engineering",
|
||||
slug: "eng",
|
||||
parentId: "t-1",
|
||||
type: "USER_GROUP",
|
||||
},
|
||||
{
|
||||
id: "g-2",
|
||||
name: "Sales",
|
||||
slug: "sales",
|
||||
parentId: "t-1",
|
||||
type: "USER_GROUP",
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("should show bulk action bar when users are selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users");
|
||||
// 로딩바 대기 대신 실제 데이터 텍스트 대기
|
||||
const table = page.locator("table");
|
||||
await expect(table).toContainText("User One", { timeout: 20000 });
|
||||
|
||||
// 첫 번째 데이터의 체크박스 선택
|
||||
const userCheckbox = page.locator('table input[type="checkbox"]').nth(1);
|
||||
await userCheckbox.click();
|
||||
|
||||
// 일괄 작업 바 확인
|
||||
const selectionBar = page.getByTestId("bulk-action-bar");
|
||||
await expect(selectionBar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 활성화 버튼 확인
|
||||
const activeBtn = page.getByTestId("bulk-active-btn");
|
||||
await expect(activeBtn).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 전체 선택
|
||||
await page.locator('table input[type="checkbox"]').first().click();
|
||||
await expect(selectionBar).toBeVisible();
|
||||
|
||||
// 선택 해제 버튼
|
||||
const closeBtn = page.getByTestId("bulk-close-btn");
|
||||
await closeBtn.click();
|
||||
|
||||
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should filter and highlight nodes in organization tree", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/tenants/t-1");
|
||||
// 테넌트 이름이 제목으로 나올 때까지 대기
|
||||
await expect(page.locator("h2").last()).toContainText(
|
||||
/Main Tenant|상세|Profile/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
const subTenantLink = page
|
||||
.locator("a, button")
|
||||
.filter({ hasText: /조직 관리|Organization|Sub-tenant/i })
|
||||
.first();
|
||||
await subTenantLink.click();
|
||||
|
||||
// 트리 검색 입력창 대기 (더 유연한 셀렉터)
|
||||
const searchInput = page
|
||||
.locator('input[placeholder*="검색"], input[placeholder*="Search"]')
|
||||
.first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await searchInput.fill("Eng");
|
||||
|
||||
const engNode = page
|
||||
.locator('button, [role="button"]')
|
||||
.filter({ hasText: "Engineering" })
|
||||
.first();
|
||||
await expect(engNode).toBeVisible();
|
||||
await expect(engNode).toHaveClass(/bg-primary\/5/);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/바론 어드민 서비스/);
|
||||
});
|
||||
126
adminfront/tests/owners.spec.ts
Normal file
126
adminfront/tests/owners.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Tenant Owners Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
|
||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/user/me")) {
|
||||
console.log("Mocking ME");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/owners")) {
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ id: "owner-1", name: "Owner One", email: "owner1@example.com" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes("/admins")) {
|
||||
return route.fulfill({ json: [] });
|
||||
}
|
||||
if (url.includes("/admin/tenants/tenant-1")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "tenant-1",
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/users") && route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{ id: "user-2", name: "User Two", email: "user2@example.com" },
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: { items: [], total: 0 } });
|
||||
}
|
||||
return route.fulfill({ status: 200, json: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test("should list tenant owners", async ({ page }) => {
|
||||
await page.goto("/tenants/tenant-1/permissions");
|
||||
|
||||
await expect(page.locator(".animate-spin").first()).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(/테넌트 소유자|Tenant Owners/)).toBeVisible();
|
||||
await expect(page.locator("table").first()).toContainText("Owner One");
|
||||
await expect(page.locator("table").first()).toContainText(
|
||||
"owner1@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
test("should add a new owner", async ({ page }) => {
|
||||
// Specific override for this test
|
||||
await page.route(
|
||||
"**/api/v1/admin/tenants/tenant-1/owners",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({ json: [] });
|
||||
} else {
|
||||
await route.fulfill({ status: 200, json: {} });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/tenants/tenant-1/permissions");
|
||||
|
||||
await expect(page.locator(".animate-spin").first()).not.toBeVisible();
|
||||
|
||||
await page.click(
|
||||
'button:has-text("소유자 추가"), button:has-text("Add Owner")',
|
||||
);
|
||||
await page.fill(
|
||||
'input[placeholder*="사용자 검색"], input[placeholder*="Search users"]',
|
||||
"User Two",
|
||||
);
|
||||
|
||||
const addButton = page
|
||||
.locator("role=dialog")
|
||||
.getByRole("button", { name: /추가|Add/ });
|
||||
await addButton.click();
|
||||
});
|
||||
});
|
||||
197
adminfront/tests/tenants.spec.ts
Normal file
197
adminfront/tests/tenants.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Tenants Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.includes("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/tenants")) {
|
||||
if (
|
||||
route.request().method() === "GET" &&
|
||||
!url.includes("/parent-1") &&
|
||||
!url.includes("/organization")
|
||||
) {
|
||||
return route.fulfill({
|
||||
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("should list tenants", async ({ page }) => {
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
await expect(page.locator("h2").last()).toContainText(
|
||||
/테넌트 목록|Tenants/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await expect(page.locator("table")).toContainText("Tenant A", {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create a new tenant", async ({ page }) => {
|
||||
await page.goto("/tenants/new");
|
||||
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const nameInput = page.locator('input[name="name"]').first();
|
||||
await nameInput.fill("New Tenant");
|
||||
|
||||
const slugInput = page.locator('input[name="slug"]').first();
|
||||
await slugInput.fill("new-tenant");
|
||||
|
||||
await page.locator("textarea").first().fill("Description");
|
||||
|
||||
const submitBtn = page
|
||||
.locator("button")
|
||||
.filter({ hasText: /생성|Create/i })
|
||||
.first();
|
||||
await submitBtn.click();
|
||||
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test("should show validation error on empty name", async ({ page }) => {
|
||||
await page.goto("/tenants/new");
|
||||
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const submitBtn = page
|
||||
.locator("button")
|
||||
.filter({ hasText: /생성|Create/i })
|
||||
.first();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await page.locator('input[name="name"]').first().fill("Valid Name");
|
||||
await expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("should show organization hierarchy and member list distinction", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mockTenants = [
|
||||
{
|
||||
id: "parent-1",
|
||||
name: "Parent Org",
|
||||
slug: "parent-slug",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 5,
|
||||
parentId: null,
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child Team",
|
||||
slug: "child-slug",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 3,
|
||||
parentId: "parent-1",
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/organization")) {
|
||||
await route.fulfill({
|
||||
json: mockTenants,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else if (url.includes("/parent-1")) {
|
||||
await route.fulfill({
|
||||
json: mockTenants[0],
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/tenants/parent-1/organization");
|
||||
|
||||
await expect(
|
||||
page.locator(".font-bold, h2").filter({ hasText: "Parent Org" }).first(),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator(".grid .font-bold, .grid .text-sm")
|
||||
.filter({ hasText: "Child Team" })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("h3")
|
||||
.filter({ hasText: /소속 멤버|Members|구성원 관리|Member Management/i })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
type UserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserCreatePayload = {
|
||||
email: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
};
|
||||
|
||||
test("user create and delete flow", async ({ page }) => {
|
||||
const users: UserSummary[] = [];
|
||||
let idSeq = 1;
|
||||
|
||||
await page.route("**/api/v1/admin/users**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
const isCollection = path.endsWith("/api/v1/admin/users");
|
||||
const isItem = path.includes("/api/v1/admin/users/");
|
||||
|
||||
if (request.method() === "GET" && isCollection) {
|
||||
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
|
||||
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||
const offset = Number(url.searchParams.get("offset") ?? "0");
|
||||
const filtered = search
|
||||
? users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(search) ||
|
||||
user.email.toLowerCase().includes(search),
|
||||
)
|
||||
: users;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
limit,
|
||||
offset,
|
||||
total: filtered.length,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST" && isCollection) {
|
||||
const payload = request.postDataJSON() as UserCreatePayload;
|
||||
const now = new Date().toISOString();
|
||||
const user: UserSummary = {
|
||||
id: `user-${idSeq++}`,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
phone: payload.phone,
|
||||
role: payload.role ?? "user",
|
||||
status: "active",
|
||||
companyCode: payload.companyCode,
|
||||
department: payload.department,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
users.unshift(user);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE" && isItem) {
|
||||
const userId = path.split("/").pop();
|
||||
const index = users.findIndex((user) => user.id === userId);
|
||||
if (index === -1) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "User not found" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
users.splice(index, 1);
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Not found" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
|
||||
await page.getByRole("link", { name: "사용자 추가" }).click();
|
||||
await expect(page).toHaveURL(/\/users\/new$/);
|
||||
|
||||
const uniqueEmail = `playwright-${Date.now()}@example.com`;
|
||||
|
||||
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
|
||||
await page.getByLabel("이메일").fill(uniqueEmail);
|
||||
await page.getByLabel("비밀번호").fill("Test1234!");
|
||||
await page.getByLabel("이름").fill("Playwright User");
|
||||
await page.getByLabel("전화번호").fill("010-0000-0000");
|
||||
await page.getByLabel("회사 코드").fill("E2E");
|
||||
await page.getByLabel("부서").fill("QA");
|
||||
await page.getByLabel("역할 (Role)").selectOption("admin");
|
||||
|
||||
await page.getByRole("button", { name: "사용자 생성" }).click();
|
||||
await expect(page).toHaveURL(/\/users$/);
|
||||
|
||||
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
|
||||
await expect(createdRow).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
|
||||
|
||||
await expect(
|
||||
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
335
adminfront/tests/users.spec.ts
Normal file
335
adminfront/tests/users.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("User Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
id_token: "fake-id-token",
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile email",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("oidc.state", "dummy");
|
||||
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
if (route.request().url().includes("/.well-known/openid-configuration")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
},
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
|
||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
const url = route.request().url();
|
||||
const method = route.request().method();
|
||||
|
||||
if (url.includes("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants(\?.*)?$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "t-1",
|
||||
slug: "test-tenant",
|
||||
name: "Test Tenant",
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "loginId",
|
||||
label: "Login ID",
|
||||
type: "text",
|
||||
isLoginId: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "t-1",
|
||||
slug: "test-tenant",
|
||||
name: "Test Tenant",
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "loginId",
|
||||
label: "Login ID",
|
||||
type: "text",
|
||||
isLoginId: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe",
|
||||
tenantSlug: "test-tenant",
|
||||
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
role: "user",
|
||||
status: "active",
|
||||
metadata: { "t-1": { loginId: "johndoe" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/password/policy")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/rp-history")) {
|
||||
return route.fulfill({
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users(\?.*)?$/) && method === "POST") {
|
||||
// Parse request payload to simulate validation checks
|
||||
const postData = route.request().postDataJSON();
|
||||
if (postData && postData.metadata?.loginId === "existing_user") {
|
||||
// Simulate a backend conflict error (409) for an existing loginId
|
||||
return route.fulfill({
|
||||
status: 409,
|
||||
json: {
|
||||
error: "이미 존재하는 로그인 ID 입니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
// Mock successful user creation
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "new-user-id",
|
||||
name: "New User",
|
||||
email: "newuser@test.com",
|
||||
loginId: postData?.metadata?.loginId || "newuser123",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users\/u-1$/) && method === "PUT") {
|
||||
const postData = route.request().postData();
|
||||
console.log("PUT /admin/users/u-1 payload:", postData);
|
||||
|
||||
// Force 409 error for this specific conflict string
|
||||
if (postData?.includes("johndoe_conflict")) {
|
||||
return route.fulfill({
|
||||
status: 409,
|
||||
json: {
|
||||
error: "이미 존재하는 로그인 ID 입니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mock successful user update
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe Updated",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe_updated",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users(\?.*)?$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
test("should successfully edit a user's Login ID", async ({ page }) => {
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
// "테넌트 프로필" 탭 클릭
|
||||
await page
|
||||
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
|
||||
.click();
|
||||
|
||||
// Wait for the form to load with the existing login ID
|
||||
const loginIdInput = page.locator(
|
||||
'input[name*="metadata"][name*="loginId"]',
|
||||
);
|
||||
await expect(loginIdInput).toBeVisible();
|
||||
await expect(loginIdInput).toHaveValue("johndoe");
|
||||
|
||||
// Change the Login ID
|
||||
await loginIdInput.fill("johndoe_updated");
|
||||
|
||||
// Submit the form using Enter key
|
||||
await loginIdInput.press("Enter");
|
||||
|
||||
// Check for success message
|
||||
await expect(page.getByText(/저장/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show conflict error when updating to an existing Login ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Intercept ANY PUT request to this user and return 409
|
||||
await page.route(/\/admin\/users\/u-1/, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
return route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "이미 존재하는 로그인 ID 입니다." }),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
// "테넌트 프로필" 탭 클릭
|
||||
await page
|
||||
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
|
||||
.click();
|
||||
|
||||
const loginIdInput = page.locator(
|
||||
'input[name*="metadata"][name*="loginId"]',
|
||||
);
|
||||
await expect(loginIdInput).toBeVisible();
|
||||
await expect(loginIdInput).toHaveValue("johndoe");
|
||||
|
||||
// Use a value similar to the successful edit test
|
||||
await loginIdInput.fill("johndoe_conflict");
|
||||
|
||||
// Submit the form using Enter key
|
||||
await loginIdInput.press("Enter");
|
||||
|
||||
// Check for the specific error
|
||||
await expect(
|
||||
page.getByText(/이미 존재하는 로그인 ID 입니다/i).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should successfully create a new user with a Login ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/new");
|
||||
|
||||
// Ensure the page title is loaded
|
||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||
|
||||
// Select Tenant first (important for schema fields to show up)
|
||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||
|
||||
// Fill required fields
|
||||
await page.locator('input[name="name"]').fill("New User");
|
||||
await page.locator('input[name="email"]').fill("newuser@test.com");
|
||||
|
||||
// Fill Login ID
|
||||
const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
|
||||
await loginIdInput.fill("newuser123");
|
||||
|
||||
// Submit the form
|
||||
const createButton = page.getByRole("button", { name: /생성/i });
|
||||
await createButton.click();
|
||||
|
||||
// Assuming successful creation redirects back to the user list
|
||||
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should show conflict error when creating with an existing Login ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/new");
|
||||
|
||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||
|
||||
// Select Tenant first (important for schema fields to show up)
|
||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||
|
||||
// Fill required fields
|
||||
await page.locator('input[name="name"]').fill("New User");
|
||||
await page.locator('input[name="email"]').fill("newuser@test.com");
|
||||
|
||||
// Fill Login ID that triggers the mock conflict error
|
||||
const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
|
||||
await loginIdInput.fill("existing_user");
|
||||
|
||||
// Submit the form
|
||||
const createButton = page.getByRole("button", { name: /생성/i });
|
||||
await createButton.click();
|
||||
|
||||
// Check for the specific conflict error message from the backend mock
|
||||
await expect(
|
||||
page.getByText(/이미 존재하는 로그인 ID 입니다/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
115
adminfront/tests/users_bulk.spec.ts
Normal file
115
adminfront/tests/users_bulk.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Users Bulk Upload", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.includes("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
if (url.includes("/admin/users")) {
|
||||
if (!url.includes("/bulk")) {
|
||||
return route.fulfill({
|
||||
json: { items: [], total: 0, limit: 50, offset: 0 },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (url.includes("/admin/tenants")) {
|
||||
return route.fulfill({
|
||||
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("should open bulk upload modal and show preview", async ({ page }) => {
|
||||
await page.goto("/users");
|
||||
// 헤더 타이틀이 뜰 때까지 대기
|
||||
await expect(page.getByTestId("page-title")).toContainText(
|
||||
/사용자|Users/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
const bulkBtn = page.getByTestId("bulk-import-btn");
|
||||
await bulkBtn.click();
|
||||
|
||||
await expect(page.getByTestId("bulk-upload-title")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
const downloadBtn = page
|
||||
.locator("button")
|
||||
.filter({ hasText: /템플릿 다운로드|템플릿 받기|Download Template/ })
|
||||
.first();
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show success results after mock upload", async ({ page }) => {
|
||||
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
{ email: "success@test.com", success: true, userId: "u-1" },
|
||||
{
|
||||
email: "fail@test.com",
|
||||
success: false,
|
||||
message: "Invalid format",
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByTestId("page-title")).toContainText(
|
||||
/사용자|Users/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
const bulkBtn = page.getByTestId("bulk-import-btn");
|
||||
await bulkBtn.click();
|
||||
|
||||
const uploadBtn = page.getByTestId("bulk-start-btn");
|
||||
await expect(uploadBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
233
adminfront/tests/users_schema.spec.ts
Normal file
233
adminfront/tests/users_schema.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("User Schema Dynamic Form", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
id_token: "fake-id-token",
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile email",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
|
||||
// Mock oidc state to prevent redirection if the library checks it
|
||||
window.localStorage.setItem("oidc.state", "dummy");
|
||||
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
if (route.request().url().includes("/.well-known/openid-configuration")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
},
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
|
||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/user/me")) {
|
||||
console.log("Mocking /user/me");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
manageableTenants: [
|
||||
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants\/t-1$/)) {
|
||||
console.log("Mocking /admin/tenants/t-1");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "t-1",
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "emp_id",
|
||||
label: "Employee ID",
|
||||
required: true,
|
||||
validation: "^E[0-9]{3}$",
|
||||
},
|
||||
{
|
||||
key: "loginId",
|
||||
label: "Login ID",
|
||||
required: true,
|
||||
isLoginId: true,
|
||||
},
|
||||
{
|
||||
key: "salary",
|
||||
label: "Salary",
|
||||
adminOnly: true,
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users\/u-1$/)) {
|
||||
console.log("Mocking /admin/users/u-1");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
tenantSlug: "test-tenant",
|
||||
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
joinedTenants: [
|
||||
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
],
|
||||
metadata: {
|
||||
"t-1": { emp_id: "E123", salary: 1000, loginId: "johndoe" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/password/policy")) {
|
||||
console.log("Mocking /password/policy");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/rp-history")) {
|
||||
console.log("Mocking /rp-history");
|
||||
return route.fulfill({
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants(\?.*)?$/)) {
|
||||
console.log("Mocking /admin/tenants");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "t-1",
|
||||
slug: "test-tenant",
|
||||
name: "Test Tenant",
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "emp_id",
|
||||
label: "Employee ID",
|
||||
required: true,
|
||||
validation: "^E[0-9]{3}$",
|
||||
},
|
||||
{
|
||||
key: "loginId",
|
||||
label: "Login ID",
|
||||
required: true,
|
||||
isLoginId: true,
|
||||
},
|
||||
{
|
||||
key: "salary",
|
||||
label: "Salary",
|
||||
adminOnly: true,
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Mocking default empty list for:", url);
|
||||
return route.fulfill({ json: { items: [], total: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
test("should render custom fields from schema in user detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
// "테넌트 프로필" 탭 클릭
|
||||
await page
|
||||
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
|
||||
.click();
|
||||
|
||||
// 섹션 헤더 확인
|
||||
const header = page
|
||||
.getByText(/테넌트별 프로필 관리|Per-tenant Profile/i)
|
||||
.first();
|
||||
await header.waitFor({ state: "visible" });
|
||||
|
||||
// 커스텀 필드 레이블 확인
|
||||
await expect(page.getByText("Employee ID")).toBeVisible();
|
||||
|
||||
// input 값 확인 (id에 t-1.emp_id가 포함됨)
|
||||
const empIdInput = page.locator('input[id*="emp_id"]');
|
||||
await expect(empIdInput).toHaveValue("E123");
|
||||
|
||||
const salaryInput = page.locator('input[id*="salary"]');
|
||||
await expect(salaryInput).toHaveValue("1000");
|
||||
|
||||
await expect(page.getByText(/Admin Only/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show regex validation error for custom field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
// "테넌트 프로필" 탭 클릭
|
||||
await page
|
||||
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
|
||||
.click();
|
||||
|
||||
const empIdInput = page.locator('input[id*="emp_id"]');
|
||||
await empIdInput.waitFor({ state: "visible" });
|
||||
await empIdInput.fill("invalid");
|
||||
|
||||
// Press Enter to trigger form submission and validation
|
||||
await empIdInput.press("Enter");
|
||||
|
||||
// 에러 메시지 확인
|
||||
const errorMsg = page
|
||||
.getByText(/형식이 올바르지 않습니다|Invalid format/i)
|
||||
.first();
|
||||
await expect(errorMsg).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
envPrefix: ["VITE_", "USERFRONT_"],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
host: "127.0.0.1",
|
||||
// 인스턴스별 도메인을 자동으로 허용
|
||||
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1","adminb.hmac.kr"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1", "adminb.hmac.kr"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
12
adminfront/vitest.config.ts
Normal file
12
adminfront/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
52
backend/cmd/fix_kratos_roles.go
Normal file
52
backend/cmd/fix_kratos_roles.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
identities, err := kratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list identities: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, id := range identities {
|
||||
traits := id.Traits
|
||||
changed := false
|
||||
|
||||
if r, ok := traits["role"].(string); ok {
|
||||
norm := domain.NormalizeRole(r)
|
||||
if norm != r && norm == domain.RoleUser {
|
||||
traits["role"] = norm
|
||||
traits["grade"] = norm
|
||||
changed = true
|
||||
}
|
||||
} else if g, ok := traits["grade"].(string); ok {
|
||||
norm := domain.NormalizeRole(g)
|
||||
if norm != g && norm == domain.RoleUser {
|
||||
traits["role"] = norm
|
||||
traits["grade"] = norm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update %s: %v", id.ID, err)
|
||||
} else {
|
||||
count++
|
||||
fmt.Printf("Updated %s\n", id.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Total updated: %d\n", count)
|
||||
}
|
||||
33
backend/cmd/server/error_handler.go
Normal file
33
backend/cmd/server/error_handler.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/response"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newErrorHandler(appEnv string) fiber.ErrorHandler {
|
||||
return func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
if appEnv == "production" || appEnv == "stage" {
|
||||
if code >= 500 {
|
||||
slog.Error("Internal Server Error",
|
||||
"error", err.Error(),
|
||||
"path", c.Path(),
|
||||
"method", c.Method(),
|
||||
)
|
||||
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
return response.Error(c, code, response.StatusCode(code), err.Error())
|
||||
}
|
||||
}
|
||||
139
backend/cmd/server/error_handler_test.go
Normal file
139
backend/cmd/server/error_handler_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionMasksServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "Internal Server Error" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionPassesClientError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/bad", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "bad request payload")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/bad", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "bad request payload" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "bad_request" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_DevelopmentReturnsOriginalServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("dev")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "database connection failed" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/unauthorized", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/unauthorized", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["code"] != "invalid_session" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEnableDocs_DisabledInProductionLikeEnv(t *testing.T) {
|
||||
testCases := []struct {
|
||||
appEnv string
|
||||
want bool
|
||||
}{
|
||||
{appEnv: "production", want: false},
|
||||
{appEnv: "prod", want: false},
|
||||
{appEnv: "stage", want: false},
|
||||
{appEnv: "staging", want: false},
|
||||
{appEnv: "dev", want: true},
|
||||
{appEnv: "development", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
got := shouldEnableDocs(tc.appEnv)
|
||||
if got != tc.want {
|
||||
t.Fatalf("appEnv=%s expected shouldEnableDocs=%v, got %v", tc.appEnv, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
550
backend/cmd/server/headless_login_e2e_test.go
Normal file
550
backend/cmd/server/headless_login_e2e_test.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
authhandler "baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type e2eMockIdentityProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) Name() string {
|
||||
return "mock-idp"
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
args := m.Called(loginID, password)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.AuthInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) UserExists(loginID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) InitiatePasswordReset(loginID, redirectURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type e2eMockKratosAdminService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
ErrorHandler: newErrorHandler(appEnv),
|
||||
})
|
||||
|
||||
app.Use(requestid.New(requestid.Config{
|
||||
Generator: func() string {
|
||||
return "req-e2e-headless"
|
||||
},
|
||||
}))
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
|
||||
status := c.Response().StatusCode()
|
||||
if status < 400 {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := "http_request"
|
||||
if err != nil {
|
||||
msg = "http_request_error"
|
||||
}
|
||||
|
||||
slog.Info(msg,
|
||||
"status", status,
|
||||
"method", c.Method(),
|
||||
"path", c.Path(),
|
||||
"latency", time.Since(start).String(),
|
||||
"ip", c.IP(),
|
||||
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
|
||||
)
|
||||
return err
|
||||
})
|
||||
|
||||
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
|
||||
api := app.Group("/api/v1")
|
||||
auth := api.Group("/auth")
|
||||
auth.Post("/headless/password/login", h.HeadlessPasswordLogin)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func mustE2EHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
|
||||
keySet := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: &privateKey.PublicKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(keySet)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks: %v", err)
|
||||
}
|
||||
|
||||
var jwks map[string]any
|
||||
if err := json.Unmarshal(raw, &jwks); err != nil {
|
||||
t.Fatalf("failed to decode jwks map: %v", err)
|
||||
}
|
||||
|
||||
return privateKey, jwks
|
||||
}
|
||||
|
||||
func mustE2EHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clientID, audience string) string {
|
||||
t.Helper()
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.RS256,
|
||||
Key: jose.JSONWebKey{
|
||||
Key: privateKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(jose.RS256),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signer: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{
|
||||
Issuer: clientID,
|
||||
Subject: clientID,
|
||||
Audience: josejwt.Audience{audience},
|
||||
Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
IssuedAt: josejwt.NewNumericDate(now),
|
||||
NotBefore: josejwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||
ID: "assertion-e2e",
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign client assertion: %v", err)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func mockHydraTransportForE2E(handler http.Handler) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w.Result(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func runHeadlessPasswordLoginE2E(
|
||||
t *testing.T,
|
||||
logger *slog.Logger,
|
||||
appEnv string,
|
||||
jwks map[string]any,
|
||||
clientAssertion string,
|
||||
) (*http.Response, string) {
|
||||
return runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
appEnv,
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func runHeadlessPasswordLoginE2ERequest(
|
||||
t *testing.T,
|
||||
logger *slog.Logger,
|
||||
appEnv string,
|
||||
jwks map[string]any,
|
||||
clientAssertion string,
|
||||
requestURL string,
|
||||
headers map[string]string,
|
||||
) (*http.Response, string) {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless password login E2E tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
t.Cleanup(func() {
|
||||
slog.SetDefault(previous)
|
||||
})
|
||||
|
||||
mockIDP := new(e2eMockIdentityProvider)
|
||||
mockIDP.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
mockKratos := new(e2eMockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
jwksBody, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &authhandler.AuthHandler{
|
||||
IdpProvider: mockIDP,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransportForE2E(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessLoginE2EApp(h, appEnv)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
return resp, logBuffer.String()
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs(t *testing.T) {
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
"https://rp.example.com/oidc/token",
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
|
||||
if got["code"] != "invalid_client_assertion_audience" {
|
||||
t.Fatalf("expected detailed code, got=%v", got["code"])
|
||||
}
|
||||
if got["error"] != "Client assertion audience mismatch" {
|
||||
t.Fatalf("expected detailed error message, got=%v", got["error"])
|
||||
}
|
||||
|
||||
output := logBuffer.String()
|
||||
if !strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("expected headless failure log to include detailed reason code, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"req_id\":\"req-e2e-headless\"") {
|
||||
t.Fatalf("expected logs to include request id, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"path\":\"/api/v1/auth/headless/password/login\"") {
|
||||
t.Fatalf("expected request path in logs, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
output := logBuffer.String()
|
||||
if !strings.Contains(output, "\"expected_audiences\"") {
|
||||
t.Fatalf("expected debug logs to include expected_audiences, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"received_audiences\"") {
|
||||
t.Fatalf("expected debug logs to include received_audiences, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"received_audiences_text\":\""+receivedAudience+"\"") {
|
||||
t.Fatalf("expected debug logs to include received_audiences_text with full URL, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"expected_audiences_text\":\"http://example.com/api/v1/auth/headless/password/login, /api/v1/auth/headless/password/login\"") {
|
||||
t.Fatalf("expected debug logs to include expected_audiences_text, got=%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"login_challenge_prefix\":\"challenge-12\"") {
|
||||
t.Fatalf("expected debug logs to include login challenge prefix, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience(t *testing.T) {
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, output := runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
"production",
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://sso.hmac.kr/api/v1/auth/headless/password/login",
|
||||
map[string]string{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "sso.hmac.kr",
|
||||
},
|
||||
)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200 for forwarded https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("expected redirectTo, got=%v", got["redirectTo"])
|
||||
}
|
||||
|
||||
if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("did not expect audience mismatch log, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience(t *testing.T) {
|
||||
t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr")
|
||||
|
||||
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
receivedAudience,
|
||||
)
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
resp, output := runHeadlessPasswordLoginE2ERequest(
|
||||
t,
|
||||
logger,
|
||||
"production",
|
||||
jwks,
|
||||
clientAssertion,
|
||||
"http://sso.hmac.kr/api/v1/auth/headless/password/login",
|
||||
nil,
|
||||
)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200 for configured public https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
if got["redirectTo"] != "http://rp/cb" {
|
||||
t.Fatalf("expected redirectTo, got=%v", got["redirectTo"])
|
||||
}
|
||||
if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||
t.Fatalf("did not expect audience mismatch log, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/validator"
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -49,6 +50,10 @@ func normalizeDocsPrefix(prefix string) string {
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
func shouldEnableDocs(appEnv string) bool {
|
||||
return !logger.IsProductionLikeEnv(appEnv)
|
||||
}
|
||||
|
||||
func registerDocsRoutes(app *fiber.App, prefix string) {
|
||||
base := normalizeDocsPrefix(prefix)
|
||||
docsPath := base + "/docs"
|
||||
@@ -89,9 +94,11 @@ func main() {
|
||||
}
|
||||
|
||||
// 0. Initialize Logger
|
||||
appEnvForLogger := getEnv("APP_ENV", getEnv("GO_ENV", "dev"))
|
||||
logger.Init(logger.Config{
|
||||
ServiceName: "baron-sso",
|
||||
Environment: getEnv("GO_ENV", "dev"),
|
||||
ServiceName: "baron-sso",
|
||||
Environment: appEnvForLogger,
|
||||
LevelOverride: getEnv("BACKEND_LOG_LEVEL", ""),
|
||||
})
|
||||
// Initialize Snowflake Node (Node 2 for Baron)
|
||||
node, err := snowflake.NewNode(2)
|
||||
@@ -137,9 +144,6 @@ func main() {
|
||||
}
|
||||
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||
// -----------------------------------
|
||||
if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
}
|
||||
|
||||
// 2. Initialize DB Connections
|
||||
// ClickHouse
|
||||
@@ -213,10 +217,26 @@ func main() {
|
||||
slog.Error("❌ Bootstrap failed", "error", err)
|
||||
}
|
||||
|
||||
// [New] Initialize Keto Outbox and Worker
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db)
|
||||
ketoRelayWorker := service.NewKetoRelayWorker(ketoOutboxRepo, ketoService)
|
||||
go ketoRelayWorker.Start(context.Background())
|
||||
slog.Info("✅ Keto Relay Worker started")
|
||||
|
||||
// [Moved & Enhanced] Seed Admin Identity & Sync Local Role
|
||||
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
} else {
|
||||
// Sync role to local DB
|
||||
if err := bootstrap.SyncAdminRole(db, kratosID); err != nil {
|
||||
slog.Error("❌ Admin role sync failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Sync existing data to Keto
|
||||
if ketoService != nil {
|
||||
if err := bootstrap.SyncKetoRelations(db, ketoService); err != nil {
|
||||
slog.Warn("⚠️ Keto synchronization failed during startup", "error", err)
|
||||
if ketoOutboxRepo != nil {
|
||||
if err := bootstrap.SyncKetoRelations(db, ketoOutboxRepo); err != nil {
|
||||
slog.Warn("⚠️ Keto synchronization queueing failed during startup", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,64 +265,53 @@ func main() {
|
||||
|
||||
// 2. Initialize Handlers
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||
hydraService := service.NewHydraAdminService()
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||
|
||||
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
||||
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
headlessJWKSCache := service.NewHeadlessJWKSCacheService(redisService, nil)
|
||||
headlessJWKSWorker := service.NewHeadlessJWKSCacheWorker(hydraService, headlessJWKSCache)
|
||||
go headlessJWKSWorker.Start(context.Background())
|
||||
slog.Info("✅ Headless JWKS Cache Worker started")
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
developerService := service.NewDeveloperService(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
||||
clientDebugEnabled := logger.ClientDebugEnabled(appEnv, clientLogDebugFlag)
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Baron SSO Backend",
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||
// Global Error Handler for Production Masking
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
// Default status code
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
// Check if it's a known fiber.Error
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
// In production or stage, mask detailed 500+ errors
|
||||
if appEnv == "production" || appEnv == "stage" {
|
||||
if code >= 500 {
|
||||
// Log the actual error for developers
|
||||
slog.Error("Internal Server Error",
|
||||
"error", err.Error(),
|
||||
"path", c.Path(),
|
||||
"method", c.Method(),
|
||||
)
|
||||
// Return masked message
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"error": "Internal Server Error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For development or non-500 errors, return the actual error message
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
ErrorHandler: newErrorHandler(appEnv),
|
||||
})
|
||||
|
||||
// Middleware
|
||||
@@ -349,13 +358,45 @@ func main() {
|
||||
EnableStackTrace: true,
|
||||
}))
|
||||
|
||||
// Backfill `code` on legacy JSON error responses during migration period.
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
|
||||
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||
allowCredentials := allowedOrigins != "*"
|
||||
userfrontURL := getEnv("USERFRONT_URL", "http://sso.hmac.kr")
|
||||
baseDomain := ""
|
||||
if u, err := url.Parse(userfrontURL); err == nil {
|
||||
baseDomain = u.Hostname()
|
||||
}
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowOriginsFunc: func(origin string) bool {
|
||||
// 1. Check static allowed list
|
||||
for _, allowed := range strings.Split(allowedOrigins, ",") {
|
||||
if origin == strings.TrimSpace(allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse origin URL
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hostname := u.Hostname()
|
||||
|
||||
// 2. Check subdomains of base domain
|
||||
if baseDomain != "" && (hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. Check registered tenant domains
|
||||
// Use context.Background() as we don't have request context here easily
|
||||
allowed, _ := tenantService.IsDomainAllowed(context.Background(), hostname)
|
||||
return allowed
|
||||
},
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Test-Role, X-Mock-Role, X-Tenant-ID",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
AllowCredentials: allowCredentials,
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||
@@ -375,7 +416,7 @@ func main() {
|
||||
}))
|
||||
|
||||
// [Security] Disable Swagger/ReDoc in Production
|
||||
if appEnv != "production" {
|
||||
if shouldEnableDocs(appEnv) {
|
||||
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
||||
registerDocsRoutes(app, "")
|
||||
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
||||
@@ -383,8 +424,12 @@ func main() {
|
||||
}
|
||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
||||
} else {
|
||||
slog.Info("🔒 API Docs disabled in production")
|
||||
slog.Info("🔒 API Docs disabled in production-like environment", "app_env", appEnv)
|
||||
}
|
||||
slog.Info("Client log policy configured",
|
||||
"app_env", appEnv,
|
||||
"client_debug_enabled", clientDebugEnabled,
|
||||
)
|
||||
|
||||
// Routes
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
@@ -469,8 +514,10 @@ func main() {
|
||||
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
|
||||
Repo: auditRepo,
|
||||
ExcludePaths: map[string]struct{}{
|
||||
"/api/v1/audit": {},
|
||||
"/api/v1/client-log": {},
|
||||
"/api/v1/audit": {},
|
||||
"/api/v1/audit/auth/timeline": {},
|
||||
"/api/v1/client-log": {},
|
||||
"/api/v1/dev/audit-logs": {},
|
||||
},
|
||||
BodyDump: true,
|
||||
WorkerCount: workerCount,
|
||||
@@ -480,9 +527,17 @@ func main() {
|
||||
api.Get("/audit", auditHandler.ListLogs)
|
||||
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||
|
||||
// [New] Shared Link Public API (No Auth required)
|
||||
api.Get("/public/orgchart", tenantHandler.GetPublicOrgChart)
|
||||
|
||||
// Public Tenant Registration
|
||||
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||
|
||||
// Tenant Context Middleware (identifies tenant from Host header)
|
||||
api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{
|
||||
TenantService: tenantService,
|
||||
}))
|
||||
|
||||
// Auth Proxy Routes
|
||||
auth := api.Group("/auth")
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
@@ -491,26 +546,25 @@ func main() {
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Post("/headless/password/login", authHandler.HeadlessPasswordLogin)
|
||||
auth.Post("/headless/link/init", authHandler.HeadlessLinkInit)
|
||||
auth.Post("/headless/link/poll", authHandler.HeadlessLinkPoll)
|
||||
auth.Get("/tenant-info", authHandler.GetTenantInfo)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
|
||||
|
||||
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||
// [Added] Use POST for actual verification triggered by the user
|
||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||
auth.Post("/sms", authHandler.SendSms)
|
||||
@@ -521,7 +575,9 @@ func main() {
|
||||
|
||||
// Signup Routes
|
||||
signup := auth.Group("/signup")
|
||||
signup.Get("/tenants", authHandler.GetActiveTenants)
|
||||
signup.Post("/check-email", authHandler.CheckEmail)
|
||||
signup.Post("/check-login-id", authHandler.CheckLoginID)
|
||||
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
|
||||
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
|
||||
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
||||
@@ -534,6 +590,8 @@ func main() {
|
||||
user.Post("/me/password", authHandler.ChangeMyPassword)
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/sessions", authHandler.ListMySessions)
|
||||
user.Delete("/sessions/:id", authHandler.DeleteMySession)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
user.Get("/rp/history", authHandler.ListRpHistory)
|
||||
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
|
||||
@@ -553,17 +611,51 @@ func main() {
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
requireAnyUser := middleware.RequireRole(middleware.RBACConfig{
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser},
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
|
||||
// Tenant Management (Super Admin Only)
|
||||
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
|
||||
// [New] Shared Link Management
|
||||
admin.Post("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.CreateShareLink)
|
||||
admin.Get("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.ListShareLinks)
|
||||
admin.Delete("/share-links/:id", requireAdmin, tenantHandler.DeleteShareLink)
|
||||
|
||||
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
|
||||
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
||||
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||
admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners)
|
||||
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization")
|
||||
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
|
||||
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
|
||||
|
||||
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
org.Get("/:id", userGroupHandler.Get)
|
||||
org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||
org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||
org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||
org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||
org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||
org.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
||||
org.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
||||
|
||||
// Relying Party Management (Global List)
|
||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||
@@ -571,12 +663,12 @@ func main() {
|
||||
// Relying Party Management (Tenant Context)
|
||||
admin.Post("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"),
|
||||
relyingPartyHandler.Create)
|
||||
|
||||
admin.Get("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"),
|
||||
relyingPartyHandler.List)
|
||||
|
||||
admin.Get("/relying-parties/:id",
|
||||
@@ -586,7 +678,7 @@ func main() {
|
||||
|
||||
admin.Put("/relying-parties/:id",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "edit_config"),
|
||||
relyingPartyHandler.Update)
|
||||
|
||||
admin.Delete("/relying-parties/:id",
|
||||
@@ -595,9 +687,14 @@ func main() {
|
||||
relyingPartyHandler.Delete)
|
||||
|
||||
// Admin User Management
|
||||
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
|
||||
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
|
||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
||||
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
|
||||
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)
|
||||
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||
admin.Get("/users/:id/rp-history", requireAdmin, userHandler.GetUserRpHistory)
|
||||
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||
|
||||
@@ -608,15 +705,33 @@ func main() {
|
||||
|
||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||
dev := api.Group("/dev")
|
||||
dev.Get("/stats", devHandler.GetStats)
|
||||
dev.Get("/my-tenants", devHandler.ListMyTenants)
|
||||
dev.Get("/users", devHandler.SearchUsers)
|
||||
dev.Get("/clients", devHandler.ListClients)
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
||||
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
||||
dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache)
|
||||
dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache)
|
||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||
dev.Get("/consents", devHandler.ListConsents)
|
||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||
|
||||
// [New] Developer Registration Flow
|
||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||
dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus)
|
||||
dev.Get("/developer-request/status", devHandler.GetDeveloperRequestStatus)
|
||||
dev.Get("/developer-request/list", devHandler.ListDeveloperRequests)
|
||||
dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest)
|
||||
dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval)
|
||||
|
||||
// Webhook for Kratos courier (HTTP delivery)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
@@ -632,12 +747,20 @@ func main() {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.SendStatus(fiber.StatusBadRequest)
|
||||
}
|
||||
if !logger.ShouldAcceptClientLog(appEnv, clientLogDebugFlag, req.Level) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
level := logger.NormalizeClientLogLevel(req.Level)
|
||||
if level == slog.LevelInfo && logger.ShouldFilterNoisyClientInfo(appEnv, clientLogDebugFlag, req.Message) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
// Prepare attributes for flattening
|
||||
attrs := []any{
|
||||
slog.String("source", "client"),
|
||||
}
|
||||
for k, v := range req.Data {
|
||||
sanitizedData := logger.SanitizeClientLogData(req.Data)
|
||||
for k, v := range sanitizedData {
|
||||
// Skip svc if it's already set by the global logger to avoid confusion,
|
||||
// or keep it as client_svc
|
||||
if k == "svc" {
|
||||
@@ -646,30 +769,7 @@ func main() {
|
||||
attrs = append(attrs, slog.Any(k, v))
|
||||
}
|
||||
}
|
||||
|
||||
// Map and log with correct level
|
||||
var level slog.Level
|
||||
switch req.Level {
|
||||
case "SEVERE", "ERROR":
|
||||
level = slog.LevelError
|
||||
case "WARNING", "WARN":
|
||||
level = slog.LevelWarn
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
// Filter out noisy client navigation logs
|
||||
if level == slog.LevelInfo {
|
||||
msg := strings.ToLower(req.Message)
|
||||
if strings.Contains(msg, "navigating to") ||
|
||||
strings.Contains(msg, "going to") ||
|
||||
strings.Contains(msg, "redirecting to") ||
|
||||
strings.Contains(msg, "full paths for routes") {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Log(c.Context(), level, req.Message, attrs...)
|
||||
slog.Log(c.Context(), level, logger.SanitizeClientLogMessage(req.Message), attrs...)
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
|
||||
@@ -849,6 +849,11 @@ components:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
MessageResponse:
|
||||
type: object
|
||||
@@ -1096,6 +1101,9 @@ components:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
sessionAuthenticatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
department:
|
||||
type: string
|
||||
affiliationType:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user