forked from baron/baron-sso
Compare commits
690 Commits
stage/CDC
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4fa631ae | |||
| 20811d8b99 | |||
| 30e3c105e8 | |||
| 10add98e69 | |||
| aadc062781 | |||
| a462fd5df3 | |||
| 9cbc9828e6 | |||
| 12d8d0e832 | |||
| 6ea5920c85 | |||
| 737703683d | |||
| 05864ca70c | |||
| 1351c981a8 | |||
| 95485632a8 | |||
| a1a4620d3e | |||
| 0062633bee | |||
| ec41f8da00 | |||
| d29e4d42ed | |||
| 617040d30b | |||
| b1aaaceb51 | |||
| bec2a6b958 | |||
| 77cd05fcbf | |||
| 0ab2c01718 | |||
| 7145e703d7 | |||
| 28dad91b1a | |||
| c308d0a7d4 | |||
| 8b183cab61 | |||
| efab2a7291 | |||
| fd05c049d3 | |||
| 95ac26734a | |||
| 62d8563836 | |||
| 016d783482 | |||
| 7ea385a9f4 | |||
| 9af038271d | |||
| fb90403b7c | |||
| a56d68896f | |||
| 33249eb229 | |||
| 5f3167a503 | |||
| 69e1e32fd4 | |||
| 49560e8a8c | |||
| 08ad23d6e3 | |||
| 2a613d2a2e | |||
| baa6f5e17b | |||
| b0dbd7b32f | |||
| 7bef9c5b12 | |||
| c76148e852 | |||
| 0ff8cfd1d9 | |||
| 5f153bc370 | |||
| d8327afac8 | |||
| adb2aa4be0 | |||
| bfdfbab85f | |||
| cbb3ac2211 | |||
| c1c197e0e0 | |||
| 80ec788a2a | |||
| f353450baa | |||
| c990bd591b | |||
| 26c4666a89 | |||
| b1a8df3443 | |||
| ac3226e939 | |||
| 544aa4472a | |||
| 721f8475b3 | |||
| d3ae4c7e38 | |||
| 2cd2ce4c02 | |||
| 40eaadd88d | |||
| b1c853b3c3 | |||
| 95a2730e71 | |||
| 2a9ab0ddc5 | |||
| 82d908828f | |||
| 072a982b5a | |||
| d30a324293 | |||
| 4b2d9c89b3 | |||
| 79bf1c3496 | |||
| 92ba779ff9 | |||
| 66556c9f03 | |||
| 3819a29ed8 | |||
| 8b67b22fa5 | |||
| 2d1ae96e3e | |||
| c662552157 | |||
| 38091429f4 | |||
| b2808759d2 | |||
| 44726e5a54 | |||
| fe59b478fc | |||
| 4c068711bf | |||
| ce8a1f46a7 | |||
| 35284d72ed | |||
| 202c783920 | |||
| 4d468cd39f | |||
| 006113ebc7 | |||
| bfd9cab260 | |||
| 3cdb7ce19f | |||
| 98dd924e9f | |||
| 11403b2151 | |||
| 7e6c9459a9 | |||
| c07fcb2e94 | |||
| 50ce44c236 | |||
| 7ca0db5a4c | |||
| bd8d1d1294 | |||
| 64d48b9097 | |||
| e0ce6b6295 | |||
| b18d1159c4 | |||
| b714213b78 | |||
| 6e30570a72 | |||
| 23a3a084b8 | |||
| ce40df7ea3 | |||
| 7bf1aca2f3 | |||
| 383c6bf7b9 | |||
| d951bd825f | |||
| cc2565ef9b | |||
| e365c97dc0 | |||
| 4d5b010cbc | |||
| aca13c01a7 | |||
| ec55d4847e | |||
| af48e09904 | |||
| b5ac4e4d3f | |||
| 8e9d015443 | |||
| 35f0306456 | |||
| 09577c3257 | |||
| 7abd3069ee | |||
| bdd86f4d88 | |||
| e4680ec49d | |||
| 568dc258e7 | |||
| 2820ca941d | |||
| e41a2162da | |||
| c587f37089 | |||
| ca15e2a35c | |||
| fb7a05797c | |||
| d39838a1c9 | |||
| a70755e993 | |||
| d0bdc54286 | |||
| b96c8100e0 | |||
| 73cebd993b | |||
| 269a607302 | |||
| 5ac72be6b1 | |||
| 79845d2b6a | |||
| 01bc6d9b08 | |||
| 1b9421f3e6 | |||
| d480a01857 | |||
| 22afe6654e | |||
| c495e9119b | |||
| f60b15a17b | |||
| 0bb3ccb850 | |||
| 4d77060b5d | |||
| fd6addfffd | |||
| 679c1656f4 | |||
| b4f80a36b0 | |||
| 839ca9d407 | |||
| 1b075e049f | |||
| 5b4efae001 | |||
| 4a88e4fd97 | |||
| 01bde0925d | |||
| 2fe15efeca | |||
| b591184194 | |||
| 6ebcb43b16 | |||
| 5738469983 | |||
| 52046e4a66 | |||
| e9af231fb0 | |||
| 85c2eb1690 | |||
| 4c9d219fd4 | |||
| 2234986abd | |||
| b919f600e1 | |||
| 437a3ad98d | |||
| 3ed9e912e6 | |||
| 0f11173739 | |||
| 41e755b1c7 | |||
| 894feb20f1 | |||
| 85707500ef | |||
| c880b3c333 | |||
| 28478309fa | |||
| cad1162597 | |||
| 1341f07ef9 | |||
| 107406d113 | |||
| 67af52d8e2 | |||
| 48048a24fe | |||
| 4eb4c5af34 | |||
| f61c56cfde | |||
| 2671ebda27 | |||
| 2405961375 | |||
| ae97950108 | |||
| f726463a6c | |||
| badcabb644 | |||
| aa2848c3b6 | |||
| 9be833d2e0 | |||
| 4e81e214a3 | |||
| 561659f333 | |||
| 0b48fe22c7 | |||
| b8c1b116b1 | |||
| 57c05c9241 | |||
| 9478944197 | |||
| c9cf7d6c67 | |||
| 06d2b71e25 | |||
| 9803108de2 | |||
| fe176c6912 | |||
| 01cd7a0ad3 | |||
| 87a45f0e76 | |||
| 5670288616 | |||
| 3ab9d28c9d | |||
| 2dedeb66b6 | |||
| 1f47abb860 | |||
| a6f9d89477 | |||
| 729a9890a6 | |||
| b4883bc9eb | |||
| d54d258117 | |||
| f3e9ca52be | |||
| 1596342d03 | |||
| f6c7cb3b22 | |||
| 47d2f15283 | |||
| 29038254dd | |||
| 4bae1dd00d | |||
| ded9dfc56b | |||
| d707cdf850 | |||
| 3f4138e3a0 | |||
| 5c46727fb5 | |||
| e5ac333efa | |||
| 5377401574 | |||
| f76321c8ac | |||
| b2f155e35b | |||
| 6d3f128282 | |||
| ba3e9103f2 | |||
| 8f2e351875 | |||
| 91e983b315 | |||
| 499b5d65da | |||
| 5ba0d0fb86 | |||
| c6c79f7306 | |||
| fbdfb97c3e | |||
| 8cdd73d31a | |||
| 243b852591 | |||
| 80aa60fdf1 | |||
| af1f45cc25 | |||
| a125b1d7ae | |||
| 322fd13d67 | |||
| fcb246ea9e | |||
| 719f408e7e | |||
| ab6cb1331e | |||
| b7c963b672 | |||
| e5f1c85e29 | |||
| 74068503bb | |||
| 1f3d56933f | |||
| f76dd4e60d | |||
| bf64f82507 | |||
| ae8c2ee06f | |||
| 802bf3e91d | |||
| d32ca69eee | |||
| d6d39ca300 | |||
| 2c5eed1774 | |||
| 38605ac8a3 | |||
| a4d457073a | |||
| d0f44de2d1 | |||
| d2a7ebd82f | |||
| d40e443d48 | |||
| c4487b9334 | |||
| 57f05e2694 | |||
| 565ef6b685 | |||
| 75f192fb24 | |||
| 5c8a338085 | |||
| af55e3dbb8 | |||
| 31d107ff2e | |||
| 6574fb54b9 | |||
| 4a1e89e421 | |||
| c59ec5ce83 | |||
| 90457394b0 | |||
| 6259fb074b | |||
| 90740ffb22 | |||
| 4aa0ada012 | |||
| 22e2cc1f0f | |||
| 3c741ad0e3 | |||
| e8d76e5e95 | |||
| 520d7404cf | |||
| 86940cce9e | |||
| cadb0631fd | |||
| 420f2429c3 | |||
| bb87034898 | |||
| 07b0c055cc | |||
| 59514f4cf3 | |||
| 0f06fbc901 | |||
| 2c93bd8dfb | |||
| b4dfbe0480 | |||
| 23e3738b80 | |||
| 5648b7ec45 | |||
| a156713db7 | |||
| 041b0724be | |||
| f8d0cf411a | |||
| addded8942 | |||
| 262c5959cf | |||
| 939bf68f85 | |||
| 73ba79b015 | |||
| 955d0fb6da | |||
| 36cd693b4f | |||
| 509029f8f3 | |||
| 6512fea8fe | |||
| 7fe86e8aa4 | |||
| a010bd44c0 | |||
| 4ca492b31c | |||
| cb8c7d78c3 | |||
| bf94c7a3d6 | |||
| bdca346baa | |||
| 4c56c28481 | |||
| b74bab4161 | |||
| 16b2c97ddc | |||
| 23cd316c23 | |||
| f85def288d | |||
| d43787a96d | |||
| 5ddfc6c81b | |||
| 0448b86443 | |||
| b65d916a83 | |||
| 58a3be9a34 | |||
| 8d2e2c58fe | |||
| 2da470922b | |||
| b33aabbb68 | |||
| 5b345fcf6a | |||
| 5a98b8490c | |||
| 3e31fdfa0c | |||
| 9040d22ad2 | |||
| 29675d9cea | |||
| fcbd936053 | |||
| 963b0835ea | |||
| 00b89c04d6 | |||
| 2808c68871 | |||
| deed33aad2 | |||
| b245dd3111 | |||
| 592c1d1741 | |||
| 0e83561994 | |||
| faf6db204d | |||
| b81edb8a64 | |||
| d2270765f2 | |||
| a830242947 | |||
| 8d9ba3cfea | |||
| 62b1938c42 | |||
| 00310448e9 | |||
| 6e610c553f | |||
| c489c7c38f | |||
| 6a6730b544 | |||
| 731ae9251e | |||
| da01f63c54 | |||
| dc16958804 | |||
| 27caf27416 | |||
| 615d204678 | |||
| 2595d9ab74 | |||
| 6143569f7a | |||
| e0e60295f3 | |||
| 92c3905558 | |||
| 177a319407 | |||
| 7401454bc0 | |||
| bb5438bf8d | |||
| d88524b0f7 | |||
| 62d3923dee | |||
| 14b916fec8 | |||
| 3b073a4e11 | |||
| 200411a701 | |||
| e09e83351e | |||
| 45e49cf595 | |||
| d7a56e7352 | |||
| c7053c2c51 | |||
| d25b5bc61d | |||
| 1808cf9f33 | |||
| 899365de9d | |||
| dda1df9c48 | |||
| 35e51910c6 | |||
| 0e7ab2a22f | |||
| e240470d04 | |||
| 368f4bbad8 | |||
| 53830b20d8 | |||
| 57d92fa748 | |||
| e54802140a | |||
| e481ae2821 | |||
| 0eb6dabdc1 | |||
| dc68b7da41 | |||
| 9fc6459636 | |||
| 7c809fb478 | |||
| dbb5ad93b8 | |||
| e54cc121c7 | |||
| 66687a4c73 | |||
| c4f8d939d2 | |||
| 710f1a865c | |||
| eb46918397 | |||
| d56c041b67 | |||
| f19b694c0b | |||
| 8dfe6fed82 | |||
| 5bb1c5871c | |||
| 2d6ca2f66b | |||
| 79f99757ee | |||
| 49b78b3786 | |||
| 2c3cab78b1 | |||
| 8b61c054e7 | |||
| c46c700c60 | |||
| 8c991ec48d | |||
| 0af268021e | |||
| b55ab7bc67 | |||
| dcb442b68d | |||
| dd1238a4e4 | |||
| 16d43c5973 | |||
| c21ea29111 | |||
| fc4a2f3536 | |||
| c2dbc8fc88 | |||
| 528ceea754 | |||
| 63622dcf28 | |||
| 598f6ff9d1 | |||
| 611730f22a | |||
| c9664b5844 | |||
| a1f3604b24 | |||
| 097caf395c | |||
| 54fb7b4db6 | |||
| d1184613d8 | |||
| 222dc6f4a4 | |||
| 279bfae9ec | |||
| 7d99dba890 | |||
| e7dab0f8fd | |||
| c7d25f3611 | |||
| 5496735e2f | |||
| f4bfa7c129 | |||
| 11d535f4e3 | |||
| 53dacda5d5 | |||
| 0155ee4ee7 | |||
| 0031784c07 | |||
| 0f61425bbf | |||
| fd82dd9bdd | |||
| 58f968b0fe | |||
| 8f593cf6c0 | |||
| 42b49674cc | |||
| bb918932f4 | |||
| 9112c4fb36 | |||
| 0b54992309 | |||
| e29d056b9e | |||
| c71ece84b8 | |||
| 36fc945eaf | |||
| f22a914586 | |||
| b84c52366e | |||
| a4ffb49314 | |||
| 97c02fdba1 | |||
| f028aeb716 | |||
| e01b3475ec | |||
| cd16cb3a4a | |||
| eddab895e9 | |||
| 0f80ee4f4d | |||
| 9df69f22e8 | |||
| 974af01d34 | |||
| 18eede3a10 | |||
| 055a804f7f | |||
| 94f33a0a64 | |||
| 0bf8089120 | |||
| 0327409631 | |||
| c0894eeb8a | |||
| c9bf16cf8e | |||
| cb602de049 | |||
| faffb6dc05 | |||
| b3c360c54f | |||
| 12e37b24b0 | |||
| 153ea3bad5 | |||
| 3a0cd1cfed | |||
| 0a5ae51a68 | |||
| bdd42be57e | |||
| b387673a8a | |||
| 5ef8f933cc | |||
| 0d84dbcde1 | |||
| 4d0d4f6a63 | |||
| 55c44b1a6c | |||
| d4090b7d8d | |||
| 220e87494b | |||
| b7fbbf568d | |||
| 41fe1b09c6 | |||
| b1e617ff37 | |||
| cca8aea7a2 | |||
| 14fb155cd9 | |||
| d28a121d6c | |||
| 4346f48bbe | |||
| 16422f4e2e | |||
| eff21aaa82 | |||
| 1b483d4cbf | |||
| 8f57c6b15f | |||
| 565d03da43 | |||
| eb697e560a | |||
| 8010d3644d | |||
| 9f7b925e73 | |||
| 55d5e58783 | |||
| 4f952df003 | |||
| 412695841b | |||
| 62d765a77b | |||
| 4de7124a3c | |||
| e71e090eec | |||
| 1a0dddbd98 | |||
| dcab9205d2 | |||
| 8951de510e | |||
| 4ca562ce0e | |||
| 3f957d7a9f | |||
| c2a9e1044c | |||
| 254e34dbca | |||
| c06a5bf181 | |||
| 57456bd4cd | |||
| d1b550f6f7 | |||
| 0b92ad49da | |||
| 5cd3f04f69 | |||
| 574238c744 | |||
| 1b9687e9e8 | |||
| b4a3cc4318 | |||
| 258c91a740 | |||
| ece8df50f6 | |||
| 024e1cc5bd | |||
| 841e1f8ab2 | |||
| 79f5ace7ef | |||
| da10b4be15 | |||
| e803a0b150 | |||
| c7ed9186c9 | |||
| 76a63264fe | |||
| 481ec5fc15 | |||
| ee8cfb4ba8 | |||
| c8ac953b14 | |||
| 40d64acf15 | |||
| 4a0e5641cb | |||
| 8a8b5baaf6 | |||
| 187f0da29b | |||
| 498fdd802c | |||
| b9a351ca59 | |||
| a26093836f | |||
| d77199bdbc | |||
| d3e83332fb | |||
| 8bca127723 | |||
| 92e607aee8 | |||
| df543d6203 | |||
| 9ca73e8774 | |||
| f6f8e88342 | |||
| e36a973053 | |||
| 8a6e41d74c | |||
| 5e7b7b878c | |||
| f4ed1057a2 | |||
| f047c24a38 | |||
| 9681945f5a | |||
| 3a3bfd3c00 | |||
| a31eceaf16 | |||
| a4d707d4d8 | |||
| 629716f226 | |||
| 6ed9b2b734 | |||
| 8c2b2f71ef | |||
| ee24842225 | |||
| 5f48a1c172 | |||
| 72288f1d39 | |||
| def2f924c9 | |||
| ae0a516ee4 | |||
| 0c706a8936 | |||
| 298b919d1a | |||
| 937f2f9820 | |||
| e8a4d7544f | |||
| 878867f6cc | |||
| 250bc297fa | |||
| 68eeac90f7 | |||
| ee41083b73 | |||
| 45ce440569 | |||
| 084e8594ff | |||
| f810efd420 | |||
| 7259c62251 | |||
| 6709bf3029 | |||
| 3626584046 | |||
| a2a6938246 | |||
| a0713df85a | |||
| 48853aae99 | |||
| 5149fdc246 | |||
| 85e1a172dd | |||
| 7d7f17ab69 | |||
| 1c083dd586 | |||
| 1419c8db27 | |||
| 0655206f05 | |||
| d371bd32c8 | |||
| c0c5a23dc1 | |||
| 27f48baadc | |||
| b8a25135fc | |||
| efbf970a18 | |||
| d4c48da426 | |||
| 5e649c279f | |||
| 3063450ee0 | |||
| d3853fac2a | |||
| a98721e4f7 | |||
| 9ea7db8b3c | |||
| 7a798d8466 | |||
| 2bf0248e95 | |||
| 132cdf3eda | |||
| 360b5a6f2a | |||
| 46b661cff0 | |||
| 843b4100ad | |||
| 9a64a16cb9 | |||
| f46a7cc088 | |||
| 636da587e3 | |||
| 8307f65f6a | |||
| e78b2bea50 | |||
| 59cb482219 | |||
| 888863094d | |||
| 94db1dab08 | |||
| 5ee9a46663 | |||
| 7a9dff372b | |||
| 819ac00040 | |||
| 8bf8520d62 | |||
| 262c988226 | |||
| ef286330a2 | |||
| ab66f13afd | |||
| 074c3e30d1 | |||
| f6cf261fd5 | |||
| 9e45313b5d | |||
| 80bb6abb72 | |||
| e378fdd3e8 | |||
| 37a878fb93 | |||
| 43b4bd5a83 | |||
| 57a00c0236 | |||
| 404e5179e8 | |||
| b540482bf5 | |||
| c398237c35 | |||
| 281b690c38 | |||
| 629a1fe9a4 | |||
| 5615e9a4fb | |||
| 49778af905 | |||
| 7acf3cf5be | |||
| d1859d593d | |||
| 0d259db7ce | |||
| 64cdef81a6 | |||
| 9a87af93f1 | |||
| cbaa208f79 | |||
| 53cad429a1 | |||
| 3e8adbfbfd | |||
| 2cba9c9c1f | |||
| 45a14163bf | |||
| 5096930d68 | |||
| 3064126709 | |||
| 13dee9ae9b | |||
| 97fb89b831 | |||
| 92c547db3c | |||
| 71de98e0d9 | |||
| 6d05bb212b | |||
| 5f9a61de98 | |||
| 6cdd0fd81e | |||
| 3169dd958a | |||
| 2495fcb13d | |||
| c398fc13a4 | |||
| a2b328f3b0 | |||
| 9f78698f54 | |||
| b888b33cde | |||
| 0978adcee5 | |||
| 128ac94575 | |||
| 428ea888a7 | |||
| f9f0ed0f14 | |||
| a72df2e839 | |||
| 0664640c6f | |||
| 068d0adbd4 | |||
| 67b3420d00 | |||
| 894565d87e | |||
| 6d5a861d17 | |||
| 52936b2b88 | |||
| 613d198690 | |||
| 0fb761f284 | |||
| 572ac39e60 | |||
| 68e7fb9ba2 | |||
| 0844befb35 | |||
| e484d8c100 | |||
| 20afede89c | |||
| 3dcdd97882 | |||
| 6eb4c293ff | |||
| d16f6cdcb4 | |||
| 28a440734c | |||
| ef679d41ea | |||
| c6190bbab6 | |||
| 7d893431d1 | |||
| 790be37930 | |||
| 6c45eca3d3 | |||
| f7e4d43b16 | |||
| 24807eab0f | |||
| 4b5defcf12 | |||
| 9ce7a67f58 | |||
| 02375af08d | |||
| 01e7b15c46 | |||
| 438f844f2b | |||
| 5e0b041d0a | |||
| f4d894fe7d | |||
| 7607d8d9b9 | |||
| 0c5a302105 | |||
| eae3e0bd2a | |||
| 6be0914b65 | |||
| d0340fc062 | |||
| 955128a25a | |||
| 367368805a | |||
| 3f85f6cfe3 | |||
| b9232687b5 | |||
| 373751996a | |||
| d86c4111ad | |||
| f97b244a59 | |||
| 5acf248285 | |||
| 0c80063311 | |||
| e3f9bbf925 | |||
| ff7a786c21 | |||
| bbf29bf400 | |||
| 08aa745e30 | |||
| 3fe32b1dfe | |||
| 2f350517b0 | |||
| 8bddce43c1 | |||
| 9378a5a75d | |||
| 3de28410ae | |||
| 093d2f2af0 |
@@ -16,3 +16,4 @@
|
||||
**/*.log
|
||||
**/*.swp
|
||||
**/.DS_Store
|
||||
**/.pnpm-store
|
||||
|
||||
59
.env.sample
59
.env.sample
@@ -7,7 +7,6 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
|
||||
TZ=Asia/Seoul
|
||||
|
||||
|
||||
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# --- Infrastructure Ports ---
|
||||
@@ -33,6 +32,39 @@ BACKEND_LOG_LEVEL=
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||
|
||||
# --- NAVER WORKS API ---
|
||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
||||
|
||||
# --- NAVER WORKS Drive backup upload ---
|
||||
# Drive API 업로드에는 `file` scope가 필요합니다.
|
||||
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
|
||||
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
|
||||
WORKS_DRIVE_TARGET=sharedrive
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID=
|
||||
WORKS_DRIVE_PARENT_FILE_ID=
|
||||
WORKS_DRIVE_USER_ID=me
|
||||
WORKS_DRIVE_GROUP_ID=
|
||||
WORKS_DRIVE_SHARED_FOLDER_ID=
|
||||
WORKS_DRIVE_ACCESS_TOKEN=
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE=
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD=
|
||||
WORKS_DRIVE_OAUTH_SCOPE=file
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
|
||||
WORKS_DRIVE_OAUTH_REDIRECT_URI=
|
||||
WORKS_DRIVE_SPLIT_SIZE=9000M
|
||||
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
|
||||
WORKS_DRIVE_FORCE_SPLIT=false
|
||||
WORKS_DRIVE_OVERWRITE=false
|
||||
WORKS_DRIVE_DRY_RUN=false
|
||||
WORKS_DRIVE_UPLOAD_REPORTS=true
|
||||
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
|
||||
|
||||
|
||||
# Audit System Configuration
|
||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
@@ -77,20 +109,20 @@ HYDRA_DB=ory_hydra
|
||||
KETO_DB=ory_keto
|
||||
|
||||
# Ory Kratos Configuration
|
||||
KRATOS_VERSION=v25.4.0-distroless
|
||||
KRATOS_VERSION=v26.2.0-distroless
|
||||
# KRATOS_PUBLIC_PORT=4433 # Internal only
|
||||
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
|
||||
|
||||
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||
KRATOS_UI_NODE_VERSION=v26.2.0
|
||||
# KRATOS_UI_PORT=4455 # Internal only
|
||||
|
||||
# Ory Hydra Configuration
|
||||
HYDRA_VERSION=v25.4.0-distroless
|
||||
HYDRA_VERSION=v26.2.0-distroless
|
||||
# HYDRA_PUBLIC_PORT=4441 # Internal only
|
||||
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
|
||||
|
||||
# Ory Keto Configuration
|
||||
KETO_VERSION=v25.4.0-distroless
|
||||
KETO_VERSION=v26.2.0-distroless
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
KETO_READ_URL=http://keto:4466
|
||||
@@ -110,16 +142,23 @@ KRATOS_UI_URL=http://localhost:5000
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
# 선택: Hydra 화면 핸드오프 URL을 USERFRONT_URL 기준 기본값과 다르게 둘 때만 설정합니다.
|
||||
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
|
||||
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
|
||||
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
|
||||
# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim)
|
||||
HYDRA_REFRESH_TOKEN_TTL=720h
|
||||
|
||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=["http://localhost:5000","http://localhost:5000/","https://sso.hmac.kr","https://sso.hmac.kr/","https://sso.hmac.kr/ko","https://sso.hmac.kr/ko/","https://sso.hmac.kr/en","https://sso.hmac.kr/en/","https://sso.hmac.kr/auth/callback","https://sso.hmac.kr/ko/auth/callback","https://sso.hmac.kr/en/auth/callback","http://localhost:5173/auth/callback","http://localhost:5174/auth/callback","http://localhost:5175/auth/callback","https://sso.hmac.kr/orgfront/auth/callback"]
|
||||
|
||||
# Oathkeeper JWKS (내부 통신용)
|
||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||
|
||||
# Oathkeeper 실행 사용자/프로브 설정
|
||||
OATHKEEPER_VERSION=v25.4.0
|
||||
OATHKEEPER_VERSION=v26.2.0
|
||||
OATHKEEPER_UID=1001
|
||||
OATHKEEPER_GID=1001
|
||||
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||
@@ -141,5 +180,9 @@ 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=
|
||||
ORGFRONT_URL=http://localhost:5175
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
|
||||
VITE_ORGCHART_URL=
|
||||
|
||||
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
|
||||
LOKI_URL=http://loki:3100/loki/api/v1/push
|
||||
|
||||
@@ -18,6 +18,30 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Validate RC build configuration
|
||||
env:
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
required_action_env="
|
||||
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
|
||||
"
|
||||
for key in ${required_action_env}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required RC build value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -89,28 +113,52 @@ jobs:
|
||||
- name: Build and push adminfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./adminfront
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
build-args: |
|
||||
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=adminfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push devfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./devfront
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push orgfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
build-args: |
|
||||
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=orgfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push userfront RC image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./userfront
|
||||
context: .
|
||||
file: ./userfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
provenance: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
279
.gitea/workflows/image_publish.yml
Normal file
279
.gitea/workflows/image_publish.yml
Normal file
@@ -0,0 +1,279 @@
|
||||
name: Publish Baron SSO Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_prefix:
|
||||
description: "stage/prod 공용 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
- name: Validate publish inputs
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then
|
||||
echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
required_values="
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]; then
|
||||
echo "::error::Missing WORKS Drive access auth. Provide WORKS_DRIVE_ACCESS_TOKEN, WORKS_DRIVE_ACCESS_TOKEN_FILE, WORKS_DRIVE_ACCESS_TOKEN_CMD, or WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ] \
|
||||
&& [ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ] \
|
||||
&& { [ -z "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ] || [ -z "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]; }; then
|
||||
echo "::error::WORKS_DRIVE_OAUTH_CLIENT_ID and WORKS_DRIVE_OAUTH_CLIENT_SECRET are required when WORKS_DRIVE_OAUTH_REFRESH_TOKEN is the selected auth source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compute commit-hash image tag
|
||||
id: version
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
short_sha="$(git rev-parse --short=4 HEAD)"
|
||||
if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then
|
||||
echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image_tag="${VERSION_PREFIX}.${short_sha}"
|
||||
echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Computed shared image tag: ${image_tag}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build userfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./userfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/userfront:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build adminfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/adminfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=adminfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build devfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/devfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build orgfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/orgfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=orgfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Verify built Docker images before WORKS upload
|
||||
env:
|
||||
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
image_ref="baron_sso/${image}:${IMAGE_TAG}"
|
||||
echo "Checking built Docker image before WORKS upload: ${image_ref}"
|
||||
docker image inspect "${image_ref}" >/dev/null
|
||||
docker image ls "${image_ref}"
|
||||
done
|
||||
|
||||
- name: Resolve WORKS Drive access token
|
||||
env:
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
access_token=""
|
||||
rotated_refresh_token_file="${RUNNER_TEMP}/works-drive-rotated-refresh-token"
|
||||
|
||||
if [ -n "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ]; then
|
||||
access_token="${WORKS_DRIVE_ACCESS_TOKEN_INPUT}"
|
||||
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]; then
|
||||
access_token="$(sed -n '1p' "${WORKS_DRIVE_ACCESS_TOKEN_FILE}")"
|
||||
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]; then
|
||||
access_token="$(sh -c "${WORKS_DRIVE_ACCESS_TOKEN_CMD}")"
|
||||
else
|
||||
token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
|
||||
response="$(curl -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "grant_type=refresh_token" \
|
||||
--data-urlencode "refresh_token=${WORKS_DRIVE_OAUTH_REFRESH_TOKEN}" \
|
||||
--data-urlencode "client_id=${WORKS_DRIVE_OAUTH_CLIENT_ID}" \
|
||||
--data-urlencode "client_secret=${WORKS_DRIVE_OAUTH_CLIENT_SECRET}" \
|
||||
"${token_url}")"
|
||||
http_status="$(tail -n 1 <<<"${response}")"
|
||||
response_body="$(sed '$d' <<<"${response}")"
|
||||
if [ "${http_status}" -lt 200 ] || [ "${http_status}" -ge 300 ]; then
|
||||
echo "::error::WORKS Drive access token refresh failed with HTTP ${http_status}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
access_token="$(jq -er '.access_token' <<<"${response_body}")"
|
||||
rotated_refresh_token="$(jq -r '.refresh_token // empty' <<<"${response_body}")"
|
||||
if [ -n "${rotated_refresh_token}" ]; then
|
||||
echo "::add-mask::${rotated_refresh_token}"
|
||||
printf '%s\n' "${rotated_refresh_token}" >"${rotated_refresh_token_file}"
|
||||
chmod 600 "${rotated_refresh_token_file}"
|
||||
echo "WORKS_DRIVE_ROTATED_REFRESH_TOKEN_FILE=${rotated_refresh_token_file}" >>"${GITHUB_ENV}"
|
||||
echo "::warning::WORKS returned a rotated refresh token. Persist it to the WORKS_DRIVE_OAUTH_REFRESH_TOKEN secret before old refresh tokens age out."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${access_token}" ]; then
|
||||
echo "::error::WORKS Drive access token could not be resolved."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::${access_token}"
|
||||
echo "WORKS_DRIVE_ACCESS_TOKEN=${access_token}" >>"${GITHUB_ENV}"
|
||||
|
||||
- name: Upload built images to WORKS Drive archive
|
||||
env:
|
||||
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR }}
|
||||
WORKS_DRIVE_TARGET: sharedrive
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${WORKS_DRIVE_DOCKER_IMAGE_DIR:=baron-sso}"
|
||||
|
||||
required_values="
|
||||
IMAGE_TAG WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required WORKS image archive value: ${key}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
images="backend userfront adminfront devfront orgfront"
|
||||
image_total=5
|
||||
image_index=0
|
||||
uploaded_images=""
|
||||
|
||||
for image in ${images}; do
|
||||
image_index=$((image_index + 1))
|
||||
image_ref="baron_sso/${image}:${IMAGE_TAG}"
|
||||
echo "WORKS image upload ${image_index}/${image_total}: ${image_ref}"
|
||||
docker image inspect "${image_ref}" >/dev/null
|
||||
if DOCKER_IMAGE_REF="${image_ref}" \
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \
|
||||
WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}" \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \
|
||||
scripts/docker-image/upload_works_drive.sh; then
|
||||
uploaded_images="${uploaded_images}${uploaded_images:+ }${image_ref}"
|
||||
echo "WORKS image upload completed: ${image_ref}"
|
||||
else
|
||||
upload_status="$?"
|
||||
echo "::error::WORKS image upload failed at ${image_index}/${image_total}: ${image_ref}"
|
||||
echo "Already uploaded images: ${uploaded_images:-none}"
|
||||
exit "${upload_status}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Uploaded WORKS image archives:"
|
||||
for image_ref in ${uploaded_images}; do
|
||||
echo " - ${image_ref}"
|
||||
done
|
||||
122
.gitea/workflows/production_image_deploy.yml
Normal file
122
.gitea/workflows/production_image_deploy.yml
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Deploy Baron SSO Production Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy-production-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deployment scripts and templates
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Build production deployment bundle
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
|
||||
IMAGE_DEPLOY_ENV: production
|
||||
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.PROD_INSTANCE_NAME }}
|
||||
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.PROD_PORT_PREFIX }}
|
||||
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.PROD_FRONTEND_URL }}
|
||||
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz
|
||||
ADMINFRONT_URL: ${{ vars.PROD_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.PROD_DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.PROD_ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.PROD_VITE_OIDC_AUTHORITY }}
|
||||
IMAGE_DEPLOY_BACKEND_LOG_LEVEL: ${{ vars.PROD_BACKEND_LOG_LEVEL }}
|
||||
IMAGE_DEPLOY_CLIENT_LOG_DEBUG: ${{ vars.PROD_CLIENT_LOG_DEBUG }}
|
||||
IMAGE_DEPLOY_BACKEND_PUBLIC_URL: ${{ vars.PROD_BACKEND_URL || vars.PROD_FRONTEND_URL }}
|
||||
IMAGE_DEPLOY_BACKEND_URL: ${{ vars.PROD_BACKEND_URL || vars.PROD_FRONTEND_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.PROD_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.PROD_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
PROFILE_CACHE_TTL: ${{ vars.PROD_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY: ${{ secrets.PROD_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY: ${{ secrets.PROD_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID: ${{ vars.PROD_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER: ${{ vars.PROD_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION: ${{ vars.PROD_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.PROD_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER: ${{ vars.PROD_AWS_SES_SENDER }}
|
||||
CORS_ALLOWED_ORIGINS: ${{ vars.PROD_CORS_ALLOWED_ORIGINS }}
|
||||
OATHKEEPER_API_URL: ${{ vars.PROD_OATHKEEPER_API_URL }}
|
||||
CLICKHOUSE_HOST: ${{ vars.PROD_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER: ${{ vars.PROD_CLICKHOUSE_USER }}
|
||||
IMAGE_DEPLOY_DB_PORT: ${{ vars.PROD_DB_PORT }}
|
||||
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.PROD_REDIS_PORT }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.PROD_CLICKHOUSE_PORT_HTTP }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}
|
||||
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.PROD_BACKEND_PORT }}
|
||||
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.PROD_FRONTEND_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.PROD_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.PROD_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.PROD_ORGFRONT_PORT }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }}
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }}
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.PROD_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.PROD_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.PROD_ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.PROD_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.PROD_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.PROD_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.PROD_KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.PROD_HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.PROD_KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.PROD_KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.PROD_HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.PROD_KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.PROD_OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.PROD_ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.PROD_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.PROD_OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.PROD_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||
BACKEND_IMAGE_NAME: baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: baron_sso/orgfront
|
||||
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
|
||||
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_SECRET }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }}
|
||||
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }}
|
||||
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
|
||||
IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.PROD_CSRF_COOKIE_SECRET }}
|
||||
IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.PROD_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same image tag contract as staging: production must consume the
|
||||
# immutable image tag that already passed staging verification.
|
||||
scripts/deploy/build_image_deploy_bundle.sh
|
||||
|
||||
- name: Upload bundle and run requested production image tag
|
||||
env:
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz
|
||||
DEPLOY_HOST: ${{ vars.PROD_HOST }}
|
||||
DEPLOY_USER: ${{ vars.PROD_USER }}
|
||||
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
|
||||
WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
|
||||
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/deploy/upload_and_run_image_deploy.sh
|
||||
@@ -42,19 +42,13 @@ jobs:
|
||||
sudo apt-get update -y && sudo apt-get install -y skopeo
|
||||
fi
|
||||
|
||||
# Re-tag backend image
|
||||
echo "Re-tagging backend image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
|
||||
|
||||
# Re-tag userfront image
|
||||
echo "Re-tagging userfront image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
echo "Re-tagging ${image} image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
|
||||
done
|
||||
|
||||
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -68,6 +62,9 @@ jobs:
|
||||
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
|
||||
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
@@ -89,7 +86,7 @@ jobs:
|
||||
|
||||
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
|
||||
|
||||
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'"
|
||||
|
||||
# Create the main .env file for Baron SSO on the remote server
|
||||
# Note: All values are pulled from Gitea secrets and variables
|
||||
@@ -101,27 +98,56 @@ jobs:
|
||||
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
|
||||
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
|
||||
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
|
||||
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
|
||||
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"BACKEND_PORT=3000" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
|
||||
"ADMINFRONT_PORT=${{ vars.PROD_ADMINFRONT_PORT }}" \
|
||||
"DEVFRONT_PORT=${{ vars.PROD_DEVFRONT_PORT }}" \
|
||||
"ORGFRONT_PORT=${{ vars.PROD_ORGFRONT_PORT }}" \
|
||||
"DB_USER=${{ vars.PROD_DB_USER }}" \
|
||||
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
|
||||
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
|
||||
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
|
||||
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
|
||||
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
|
||||
"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 }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \
|
||||
"NAVER_CLOUD_ACCESS_KEY=${{ secrets.PROD_NAVER_CLOUD_ACCESS_KEY }}" \
|
||||
"NAVER_CLOUD_SECRET_KEY=${{ secrets.PROD_NAVER_CLOUD_SECRET_KEY }}" \
|
||||
"NAVER_CLOUD_SERVICE_ID=${{ vars.PROD_NAVER_CLOUD_SERVICE_ID }}" \
|
||||
"NAVER_SENDER_PHONE_NUMBER=${{ vars.PROD_NAVER_SENDER_PHONE_NUMBER }}" \
|
||||
"AWS_REGION=${{ vars.PROD_AWS_REGION }}" \
|
||||
"AWS_ACCESS_KEY_ID=${{ vars.PROD_AWS_ACCESS_KEY_ID }}" \
|
||||
"AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}" \
|
||||
"AWS_SES_SENDER=${{ vars.PROD_AWS_SES_SENDER }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
|
||||
"ADMINFRONT_URL=${{ vars.PROD_ADMINFRONT_URL }}" \
|
||||
"DEVFRONT_URL=${{ vars.PROD_DEVFRONT_URL }}" \
|
||||
"ORGFRONT_URL=${{ vars.PROD_ORGFRONT_URL }}" \
|
||||
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
||||
"VITE_OIDC_AUTHORITY=${{ vars.PROD_VITE_OIDC_AUTHORITY }}" \
|
||||
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.PROD_HYDRA_REFRESH_TOKEN_TTL }}" \
|
||||
"ADMINFRONT_CALLBACK_URLS=${{ vars.PROD_ADMINFRONT_CALLBACK_URLS }}" \
|
||||
"DEVFRONT_CALLBACK_URLS=${{ vars.PROD_DEVFRONT_CALLBACK_URLS }}" \
|
||||
"ORGFRONT_CALLBACK_URLS=${{ vars.PROD_ORGFRONT_CALLBACK_URLS }}" \
|
||||
> .env
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV TZ DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
PROD_BACKEND_PORT BACKEND_PORT USERFRONT_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
|
||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
|
||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
|
||||
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL
|
||||
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required production .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy compose template and .env file to the remote server
|
||||
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
|
||||
@@ -130,6 +156,9 @@ jobs:
|
||||
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
|
||||
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
|
||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||
|
||||
83
.gitea/workflows/staging_build_check.yml
Normal file
83
.gitea/workflows/staging_build_check.yml
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Staging Build Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".gitea/workflows/staging_build_check.yml"
|
||||
- "docker/staging_pull_compose.template.yaml"
|
||||
- "adminfront/**"
|
||||
- "devfront/**"
|
||||
- "userfront/**"
|
||||
- "backend/**"
|
||||
- "common/**"
|
||||
- "scripts/**"
|
||||
- "locales/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: adminfront
|
||||
- service: devfront
|
||||
- service: userfront
|
||||
- service: backend
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare staging build inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cat <<'EOF' > .env
|
||||
APP_ENV=stage
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
ADMINFRONT_URL=https://adminfront.staging.example.com
|
||||
DEVFRONT_URL=https://devfront.staging.example.com
|
||||
USERFRONT_URL=https://userfront.staging.example.com
|
||||
ORGFRONT_URL=https://orgfront.staging.example.com
|
||||
BACKEND_URL=https://backend.staging.example.com
|
||||
BACKEND_PUBLIC_URL=https://backend.staging.example.com
|
||||
VITE_OIDC_AUTHORITY=https://sso.staging.example.com/oidc
|
||||
WORKS_ADMIN_API_BASE_URL=https://works-admin.staging.example.com/api
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://works-admin.staging.example.com/oauth/token
|
||||
ORY_POSTGRES_USER=ory
|
||||
ORY_POSTGRES_PASSWORD=ory-password
|
||||
COOKIE_SECRET=staging-build-cookie-secret
|
||||
JWT_SECRET=staging-build-jwt-secret
|
||||
NAVER_CLOUD_ACCESS_KEY=dummy
|
||||
NAVER_CLOUD_SECRET_KEY=dummy
|
||||
NAVER_CLOUD_SERVICE_ID=dummy
|
||||
NAVER_SENDER_PHONE_NUMBER=00000000000
|
||||
AWS_REGION=ap-northeast-2
|
||||
AWS_ACCESS_KEY_ID=dummy
|
||||
AWS_SECRET_ACCESS_KEY=dummy
|
||||
AWS_SES_SENDER=dummy@example.com
|
||||
REDIS_ADDR=redis:6389
|
||||
CLICKHOUSE_PORT_NATIVE=9000
|
||||
CLICKHOUSE_USER=baron
|
||||
CLICKHOUSE_PASSWORD=password
|
||||
HYDRA_PUBLIC_URL=https://hydra.staging.example.com
|
||||
KRATOS_BROWSER_URL=https://sso.staging.example.com
|
||||
KRATOS_ADMIN_URL=http://kratos:4434
|
||||
KRATOS_UI_URL=https://sso.staging.example.com
|
||||
EOF
|
||||
|
||||
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
|
||||
|
||||
- name: Build ${{ matrix.service }} with staging compose
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose -f staging_pull_compose.yaml build --pull --progress=plain "${{ matrix.service }}"
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ secrets.STG_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 }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STG_HOST }}
|
||||
STAGE_USER: ${{ vars.STG_USER }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -48,89 +48,99 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
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 }}
|
||||
DB_PORT=${{ vars.STG_DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.STG_CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
BACKEND_PORT=${{ vars.STG_BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.STG_ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.STG_USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_USER=${{ vars.STG_DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
DB_NAME=${{ vars.STG_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 }}
|
||||
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.STG_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 }}
|
||||
PROFILE_CACHE_TTL=${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.STG_AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.STG_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 }}
|
||||
USERFRONT_URL=${{ vars.STG_USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.STG_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.STG_DEVFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.STG_ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.STG_BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.STG_BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.STG_OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.STG_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 }}
|
||||
ORY_POSTGRES_DB=${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB=${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.STG_KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.STG_KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.STG_KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.STG_ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.STG_KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.STG_KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.STG_KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.STG_KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.STG_HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.STG_HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.STG_JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.STG_OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.STG_OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.STG_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 }}
|
||||
# Frontend/Ory URL configs for Staging
|
||||
VITE_OIDC_AUTHORITY=${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.STG_KRATOS_ALLOWED_RETURN_URLS_JSON }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.STG_KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
|
||||
# Monitoring & Alerts
|
||||
SMS_WEBHOOK_PORT=${{ vars.STG_SMS_WEBHOOK_PORT || '8080' }}
|
||||
MONITOR_RECIPIENT_PHONES=${{ vars.STG_MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
|
||||
LOKI_URL=${{ vars.STG_LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
|
||||
EOF
|
||||
|
||||
# 코드 업데이트 (Git)
|
||||
@@ -161,8 +171,9 @@ jobs:
|
||||
done
|
||||
|
||||
|
||||
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
|
||||
chmod -R 777 docker/ory || true
|
||||
# Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다.
|
||||
bash scripts/render_ory_config.sh
|
||||
chmod -R 777 config/.generated/ory || true
|
||||
|
||||
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
|
||||
|
||||
@@ -171,11 +182,58 @@ jobs:
|
||||
# 코드 변경 반영을 위해 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 --remove-orphans --renew-anon-volumes
|
||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate kratos hydra keto oathkeeper
|
||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate ory_stack_check
|
||||
docker compose -f staging_pull_compose.yaml up -d init-rp
|
||||
|
||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||
sleep 10
|
||||
|
||||
check_container_http() {
|
||||
name="$1"
|
||||
port="$2"
|
||||
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
|
||||
i=1
|
||||
while [ "${i}" -le "${max}" ]; do
|
||||
if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- 'http://127.0.0.1:${port}/' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
|
||||
echo "Frontend ready: ${name}:${port}"
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for frontend: ${name}:${port} (${i}/${max})"
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: frontend not ready: ${name}:${port}" >&2
|
||||
docker logs "${name}" --tail 200 >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
check_container_url() {
|
||||
name="$1"
|
||||
url="$2"
|
||||
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
|
||||
i=1
|
||||
while [ "${i}" -le "${max}" ]; do
|
||||
if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- '${url}' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('${url}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
|
||||
echo "Container URL ready: ${name} ${url}"
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for container URL: ${name} ${url} (${i}/${max})"
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: container URL not ready: ${name} ${url}" >&2
|
||||
docker logs "${name}" --tail 200 >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
check_container_url baron_backend http://127.0.0.1:3000/health
|
||||
check_container_http baron_userfront 5000
|
||||
check_container_http baron_gateway 5000
|
||||
check_container_http baron_adminfront 5173
|
||||
check_container_http baron_devfront 5173
|
||||
check_container_http baron_orgfront 5175
|
||||
|
||||
echo "===== INIT-RP LOGS ====="
|
||||
docker compose -f staging_pull_compose.yaml logs init-rp || true
|
||||
|
||||
120
.gitea/workflows/staging_image_deploy.yml
Normal file
120
.gitea/workflows/staging_image_deploy.yml
Normal file
@@ -0,0 +1,120 @@
|
||||
name: Deploy Baron SSO Staging Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "스테이징에 배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy-staging-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deployment scripts and templates
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Build staging deployment bundle
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
|
||||
IMAGE_DEPLOY_ENV: stage
|
||||
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STG_INSTANCE_NAME }}
|
||||
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STG_PORT_PREFIX }}
|
||||
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.STG_USERFRONT_URL }}
|
||||
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
|
||||
ADMINFRONT_URL: ${{ vars.STG_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.STG_DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.STG_ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
IMAGE_DEPLOY_BACKEND_LOG_LEVEL: ${{ vars.STG_BACKEND_LOG_LEVEL || 'debug' }}
|
||||
IMAGE_DEPLOY_CLIENT_LOG_DEBUG: ${{ vars.STG_CLIENT_LOG_DEBUG || 'true' }}
|
||||
IMAGE_DEPLOY_BACKEND_PUBLIC_URL: ${{ vars.STG_BACKEND_URL }}
|
||||
IMAGE_DEPLOY_BACKEND_URL: ${{ vars.STG_BACKEND_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
PROFILE_CACHE_TTL: ${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY: ${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY: ${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID: ${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER: ${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION: ${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER: ${{ vars.STG_AWS_SES_SENDER }}
|
||||
CORS_ALLOWED_ORIGINS: ${{ vars.STG_CORS_ALLOWED_ORIGINS }}
|
||||
OATHKEEPER_API_URL: ${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
CLICKHOUSE_HOST: ${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER: ${{ vars.STG_CLICKHOUSE_USER }}
|
||||
IMAGE_DEPLOY_DB_PORT: ${{ vars.STG_DB_PORT }}
|
||||
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.STG_REDIS_PORT }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.STG_BACKEND_PORT }}
|
||||
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.STG_USERFRONT_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.STG_ORGFRONT_PORT }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.STG_OATHKEEPER_PROXY_PORT }}
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.STG_DOMAIN_SUFFIX }}
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.STG_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.STG_KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.STG_KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.STG_ADMIN_EMAIL }}
|
||||
BACKEND_IMAGE_NAME: baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: baron_sso/orgfront
|
||||
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.STG_DB_PASSWORD }}
|
||||
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.STG_COOKIE_SECRET }}
|
||||
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.STG_JWT_SECRET }}
|
||||
IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/deploy/build_image_deploy_bundle.sh
|
||||
|
||||
- name: Upload bundle and run requested staging image tag
|
||||
env:
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
|
||||
DEPLOY_HOST: ${{ vars.STG_HOST }}
|
||||
DEPLOY_USER: ${{ vars.STG_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
|
||||
WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
|
||||
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/deploy/upload_and_run_image_deploy.sh
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Staging
|
||||
env:
|
||||
@@ -27,11 +27,12 @@ jobs:
|
||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
|
||||
# Staging-specific variables
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STG_HOST }}
|
||||
STAGE_USER: ${{ vars.STG_USER }}
|
||||
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
@@ -57,88 +58,117 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
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=${{ vars.CLICKHOUSE_PASSWORD }}
|
||||
DB_PORT=${{ vars.STG_DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.STG_CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
BACKEND_PORT=${{ vars.STG_BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.STG_ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.STG_USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_USER=${{ vars.STG_DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
DB_NAME=${{ vars.STG_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 }}
|
||||
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.STG_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 }}
|
||||
PROFILE_CACHE_TTL=${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.STG_AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.STG_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 }}
|
||||
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||
USERFRONT_URL=${{ vars.STG_USERFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.STG_ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.STG_BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.STG_BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.STG_OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.STG_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 }}
|
||||
ORY_POSTGRES_DB=${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB=${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.STG_KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.STG_KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.STG_KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.STG_ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.STG_KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.STG_KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.STG_KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.STG_KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.STG_HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.STG_HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.STG_JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.STG_OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.STG_OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.STG_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 }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
|
||||
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
|
||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
|
||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
|
||||
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
||||
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
|
||||
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
|
||||
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL
|
||||
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
|
||||
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required staging .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 파일 복사
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
|
||||
|
||||
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||
@@ -151,9 +181,11 @@ jobs:
|
||||
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
fi
|
||||
|
||||
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
|
||||
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
|
||||
# 배포 실행
|
||||
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
|
||||
@@ -162,6 +194,7 @@ jobs:
|
||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||
@@ -173,6 +206,9 @@ jobs:
|
||||
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
|
||||
|
||||
bash scripts/render_ory_config.sh; \
|
||||
chmod -R 777 config/.generated/ory || true; \
|
||||
|
||||
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
|
||||
|
||||
|
||||
273
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
273
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
@@ -0,0 +1,273 @@
|
||||
name: Userfront E2E Full Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
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"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Run common lint checks
|
||||
run: |
|
||||
make code-check-lint
|
||||
|
||||
full-test-policy:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.policy.outputs.should_run }}
|
||||
reason: ${{ steps.policy.outputs.reason }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide whether full E2E is needed
|
||||
id: policy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
target_sha="${GITHUB_SHA}"
|
||||
should_run="true"
|
||||
reason="manual-dispatch"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
|
||||
reason="missing-full-result"
|
||||
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
|
||||
if git show-ref --verify --quiet refs/remotes/origin/badges && \
|
||||
git cat-file -e "refs/remotes/origin/badges:dev/${target_sha}/badges.json" 2>/dev/null; then
|
||||
full_message="$(
|
||||
git show "refs/remotes/origin/badges:dev/${target_sha}/badges.json" |
|
||||
node -e "let input=''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => { const data = JSON.parse(input); const keys = ['userfront-chrome', 'userfront-firefox', 'userfront-safari']; const messages = keys.map((key) => data.badges?.[key]?.message || 'unknown'); process.stdout.write(messages.join(',')); });"
|
||||
)"
|
||||
if [ -n "${full_message}" ] && ! printf '%s' "${full_message}" | grep -q "unknown"; then
|
||||
should_run="false"
|
||||
reason="full-result-exists:${full_message}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "should_run=${should_run}" >> "$GITHUB_OUTPUT"
|
||||
echo "reason=${reason}" >> "$GITHUB_OUTPUT"
|
||||
echo "target_sha=${target_sha}"
|
||||
echo "should_run=${should_run}"
|
||||
echo "reason=${reason}"
|
||||
|
||||
userfront-e2e-full:
|
||||
needs:
|
||||
- lint
|
||||
- full-test-policy
|
||||
if: ${{ needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 80
|
||||
outputs:
|
||||
chromium_desktop: ${{ steps.full-results.outputs.chromium_desktop }}
|
||||
chromium_mobile: ${{ steps.full-results.outputs.chromium_mobile }}
|
||||
firefox_desktop: ${{ steps.full-results.outputs.firefox_desktop }}
|
||||
firefox_mobile: ${{ steps.full-results.outputs.firefox_mobile }}
|
||||
webkit_desktop: ${{ steps.full-results.outputs.webkit_desktop }}
|
||||
webkit_mobile: ${{ steps.full-results.outputs.webkit_mobile }}
|
||||
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: 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: |
|
||||
cd userfront-e2e
|
||||
npm ci
|
||||
|
||||
- name: Build userfront WASM
|
||||
run: |
|
||||
cd userfront
|
||||
rm -rf build/web
|
||||
flutter build web --wasm --release
|
||||
cd ..
|
||||
node userfront/scripts/optimize-web-build.mjs userfront/build/web
|
||||
|
||||
- name: Provision full browser matrix
|
||||
run: |
|
||||
cd userfront-e2e
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: Run full userfront-e2e tests
|
||||
id: full-results
|
||||
run: |
|
||||
mkdir -p reports
|
||||
cd userfront-e2e
|
||||
workers="${PLAYWRIGHT_WORKERS:-4}"
|
||||
case "$workers" in
|
||||
''|*[!0-9]*|0) workers=4 ;;
|
||||
esac
|
||||
any_failure=0
|
||||
|
||||
run_project() {
|
||||
output_name="$1"
|
||||
project_name="$2"
|
||||
log_path="../reports/userfront-e2e-full-${project_name}.log"
|
||||
|
||||
set +e
|
||||
echo "[userfront-e2e-full] PLAYWRIGHT_WORKERS=${workers} npx playwright test --project=${project_name}" | tee "$log_path"
|
||||
PLAYWRIGHT_WORKERS="$workers" npx playwright test --project="$project_name" --reporter=list 2>&1 | tee -a "$log_path"
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
any_failure=1
|
||||
fi
|
||||
echo "${output_name}=${result}" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
run_project chromium_desktop chromium-desktop
|
||||
run_project chromium_mobile chromium-mobile-webapp
|
||||
run_project firefox_desktop firefox-desktop
|
||||
echo "firefox_mobile=skipped" >> "$GITHUB_OUTPUT"
|
||||
run_project webkit_desktop webkit-desktop
|
||||
run_project webkit_mobile webkit-mobile-webapp
|
||||
|
||||
exit "$any_failure"
|
||||
|
||||
- name: Upload userfront-e2e full artifacts
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: userfront-e2e-full-report
|
||||
path: |
|
||||
reports/userfront-e2e-full-*.log
|
||||
userfront-e2e/playwright-report
|
||||
userfront-e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
badge-updater:
|
||||
needs:
|
||||
- lint
|
||||
- full-test-policy
|
||||
- userfront-e2e-full
|
||||
if: ${{ always() && needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' && github.ref == 'refs/heads/dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Restore published badge state
|
||||
run: |
|
||||
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
|
||||
if git show-ref --verify --quiet refs/remotes/origin/badges && \
|
||||
git cat-file -e refs/remotes/origin/badges:latest/badges.json 2>/dev/null; then
|
||||
mkdir -p docs/badges
|
||||
git archive --format=tar refs/remotes/origin/badges latest | tar -x
|
||||
cp latest/* docs/badges/
|
||||
rm -rf latest
|
||||
else
|
||||
echo "No published badge state found."
|
||||
fi
|
||||
|
||||
- name: Update full E2E badge files
|
||||
env:
|
||||
USERFRONT_E2E_RESULT: ${{ needs.userfront-e2e-full.result }}
|
||||
USERFRONT_E2E_FULL: "true"
|
||||
USERFRONT_E2E_CHROMIUM_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_desktop }}
|
||||
USERFRONT_E2E_CHROMIUM_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_mobile }}
|
||||
USERFRONT_E2E_FIREFOX_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_desktop }}
|
||||
USERFRONT_E2E_FIREFOX_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_mobile }}
|
||||
USERFRONT_E2E_WEBKIT_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_desktop }}
|
||||
USERFRONT_E2E_WEBKIT_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_mobile }}
|
||||
BADGE_UPDATE_CODE_CHECK: "false"
|
||||
BADGE_SOURCE_BRANCH: dev
|
||||
BADGE_SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
node scripts/update_code_check_badges.mjs
|
||||
cat docs/badges/badges.json
|
||||
|
||||
- name: Publish full E2E badge assets
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain docs/badges)" ]; then
|
||||
echo "No badge changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BADGE_BRANCH=badges
|
||||
BADGE_WORKTREE="$(mktemp -d)"
|
||||
BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"
|
||||
BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"
|
||||
trap 'rm -rf "${BADGE_WORKTREE}"' EXIT
|
||||
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@hmac.kr"
|
||||
|
||||
git fetch origin "+refs/heads/${BADGE_BRANCH}:refs/remotes/origin/${BADGE_BRANCH}" || true
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${BADGE_BRANCH}"; then
|
||||
git worktree add --detach "${BADGE_WORKTREE}" "origin/${BADGE_BRANCH}"
|
||||
else
|
||||
git worktree add --detach "${BADGE_WORKTREE}"
|
||||
git -C "${BADGE_WORKTREE}" checkout --orphan "${BADGE_BRANCH}"
|
||||
git -C "${BADGE_WORKTREE}" rm -rf . || true
|
||||
fi
|
||||
|
||||
find "${BADGE_WORKTREE}" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
|
||||
mkdir -p "${BADGE_LATEST_DIR}" "${BADGE_SHA_DIR}"
|
||||
cp docs/badges/*.svg "${BADGE_LATEST_DIR}/"
|
||||
cp docs/badges/badges.json "${BADGE_LATEST_DIR}/badges.json"
|
||||
cp docs/badges/*.svg "${BADGE_SHA_DIR}/"
|
||||
cp docs/badges/badges.json "${BADGE_SHA_DIR}/badges.json"
|
||||
|
||||
git -C "${BADGE_WORKTREE}" add .
|
||||
if [ -z "$(git -C "${BADGE_WORKTREE}" status --porcelain)" ]; then
|
||||
echo "No published badge changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "${BADGE_WORKTREE}" commit -m "chore: publish userfront e2e full badge [skip ci]"
|
||||
git -C "${BADGE_WORKTREE}" push origin HEAD:${BADGE_BRANCH}
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.env_backup
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
@@ -8,6 +9,7 @@
|
||||
.codex/
|
||||
.serena/
|
||||
.generated/
|
||||
config/.generated/
|
||||
*.swp
|
||||
*.log
|
||||
*.out
|
||||
@@ -15,6 +17,11 @@
|
||||
.npm-cache/
|
||||
reports
|
||||
reports/*
|
||||
/backups/
|
||||
/tmp/rp-restore-*/
|
||||
config/*.pem
|
||||
common/node_modules
|
||||
common/.baron-deps-install.lock
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
@@ -39,6 +46,18 @@ userfront/.env
|
||||
|
||||
# Frontend test artifacts
|
||||
adminfront/test-results/
|
||||
adminfront/test-results.nobody-backup/
|
||||
devfront/test-results/
|
||||
orgfront/test-results/
|
||||
adminfront/playwright-report/
|
||||
devfront/playwright-report/
|
||||
orgfront/playwright-report/
|
||||
adminfront/coverage/
|
||||
devfront/coverage/
|
||||
orgfront/coverage/
|
||||
orgfront/node_modules/
|
||||
orgfront/dist/
|
||||
orgfront/.vite/
|
||||
.pnpm-store
|
||||
.playwright-mcp
|
||||
node_modules
|
||||
|
||||
458
Makefile
458
Makefile
@@ -10,7 +10,13 @@ endif
|
||||
COMPOSE_INFRA := compose.infra.yaml
|
||||
COMPOSE_ORY := compose.ory.yaml
|
||||
COMPOSE_APP := docker-compose.yaml
|
||||
AUTH_CONFIG_ENV := .generated/auth-config.env
|
||||
AUTH_CONFIG_ENV := config/.generated/auth-config.env
|
||||
DEV_SERVICES ?= backend adminfront devfront orgfront userfront
|
||||
DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net
|
||||
INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway
|
||||
ORY_CONTAINERS := ory_postgres ory_kratos ory_hydra ory_keto ory_oathkeeper ory_clickhouse ory_vector
|
||||
APP_CONTAINERS := baron_backend baron_adminfront baron_devfront baron_orgfront baron_userfront
|
||||
DROP_CONTAINERS := $(INFRA_CONTAINERS) $(ORY_CONTAINERS) $(APP_CONTAINERS) ory_stack_check
|
||||
|
||||
COMPOSE_CLI_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
@@ -18,73 +24,273 @@ COMPOSE_CLI_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
|
||||
COMPOSE_DROP_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
|
||||
DUMP_SERVICES ?= all
|
||||
RESTORE_SERVICES ?= all
|
||||
DUMP_DATASET ?= full
|
||||
RESTORE_DATASET ?=
|
||||
FILTER_SERVICES ?= postgres,ory-postgres
|
||||
OUTPUT_BACKUP ?=
|
||||
FILE_PATH ?=
|
||||
RESTORE_INPUT ?= $(or $(FILE_PATH),$(word 2,$(MAKECMDGOALS)))
|
||||
CONFIRM_RESTORE ?=
|
||||
ALLOW_NON_EMPTY_RESTORE ?= false
|
||||
DUMP_MODE ?= maintenance
|
||||
BACKUP_USE_DOCKER ?= true
|
||||
BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local
|
||||
BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile
|
||||
BACKUP_DOCKER_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
BACKUP_DOCKER_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
|
||||
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
endif
|
||||
BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE)
|
||||
DOCKER_IMAGE_REF ?=
|
||||
WORKS_DOCKER_COMMIT_CONTAINER ?=
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR ?= /tmp/baron-sso-docker-image-upload
|
||||
|
||||
.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump filter-personnel-dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works docker-image-verify-works
|
||||
|
||||
help: ## 생성된 타깃과 옵션 목록 표시
|
||||
@printf "Usage:\n make <target> [OPTION=value ...]\n\n"
|
||||
@printf "Targets:\n"
|
||||
@awk ' \
|
||||
BEGIN { current = ""; printed_section = 0 } \
|
||||
/^# --- .+ ---/ { \
|
||||
current = $$0; \
|
||||
gsub(/^# ---[[:space:]]*/, "", current); \
|
||||
gsub(/[[:space:]]*---$$/, "", current); \
|
||||
next; \
|
||||
} \
|
||||
/^[[:alnum:]_.-]+:([^=]|$$)/ { \
|
||||
line = $$0; \
|
||||
target = line; \
|
||||
sub(/:.*/, "", target); \
|
||||
if (target ~ /^\.|%/) { next } \
|
||||
if (seen[target]++) { next } \
|
||||
desc = ""; \
|
||||
if (line ~ /##/) { \
|
||||
desc = line; \
|
||||
sub(/^.*##[[:space:]]*/, "", desc); \
|
||||
} \
|
||||
if (current != "" && current != printed_section) { \
|
||||
printf "\n %s\n", current; \
|
||||
printed_section = current; \
|
||||
} \
|
||||
if (desc != "") { \
|
||||
printf " %-36s %s\n", target, desc; \
|
||||
} else { \
|
||||
printf " %-36s\n", target; \
|
||||
} \
|
||||
} \
|
||||
' Makefile
|
||||
@printf "\nOptions:\n"
|
||||
@awk ' \
|
||||
/^[A-Z][A-Z0-9_]+[[:space:]]*\?=/ { \
|
||||
name = $$1; \
|
||||
value = $$0; \
|
||||
sub(/[[:space:]]*\?=.*/, "", name); \
|
||||
sub(/^[^?]+\?=[[:space:]]*/, "", value); \
|
||||
printf " %-32s default: %s\n", name, value; \
|
||||
} \
|
||||
' Makefile
|
||||
@printf "\nRestore Safety:\n"
|
||||
@printf " CONFIRM_RESTORE=baron-sso 복구 실행 의도를 명시하는 필수 확인값\n"
|
||||
@printf " ALLOW_NON_EMPTY_RESTORE=true 비어 있지 않은 복구 대상에 덮어쓰는 승인된 복구에서만 사용\n"
|
||||
@printf "\nRestore Examples:\n"
|
||||
@printf " make restore-plan FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso\n"
|
||||
@printf " make restore FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso ALLOW_NON_EMPTY_RESTORE=true\n"
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
build-auth-config: ## 인증 설정 파일 생성
|
||||
@echo "Building auth config..."
|
||||
@mkdir -p .generated
|
||||
@mkdir -p config/.generated
|
||||
@bash scripts/auth_config.sh build
|
||||
|
||||
validate-auth-config: build-auth-config
|
||||
validate-auth-config: build-auth-config ## 인증 설정 값 검증
|
||||
@echo "Validating auth config..."
|
||||
@bash scripts/auth_config.sh validate
|
||||
|
||||
verify-auth-config: validate-auth-config
|
||||
verify-auth-config: validate-auth-config ## 인증 설정 연결 상태 확인
|
||||
@echo "Verifying auth config wiring..."
|
||||
@bash scripts/auth_config.sh verify
|
||||
|
||||
render-ory-config: validate-auth-config ## Ory 설정 파일 렌더링
|
||||
@echo "Rendering Ory config..."
|
||||
@bash scripts/render_ory_config.sh
|
||||
|
||||
# --- 기본 실행 ---
|
||||
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||
up-all: validate-auth-config
|
||||
up: up-all ## 전체 로컬 스택 실행
|
||||
|
||||
up-all: ensure-networks render-ory-config ## 인프라, Ory, 앱 스택 모두 실행
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -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 --build -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra:
|
||||
up-infra: ensure-networks ## 인프라 스택 실행
|
||||
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||
docker compose -f $(COMPOSE_INFRA) up -d
|
||||
|
||||
up-ory: validate-auth-config
|
||||
up-ory: ensure-networks render-ory-config ## Ory 스택 실행
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
|
||||
|
||||
up-app: validate-auth-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
|
||||
up-app: ensure-networks render-ory-config ## 앱 스택 실행
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d
|
||||
|
||||
up-backend: validate-auth-config
|
||||
up-backend: ensure-networks render-ory-config ## 백엔드 컨테이너만 실행
|
||||
@echo "Starting Backend only..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
|
||||
|
||||
up-dev: up-infra up-ory
|
||||
ensure-networks: ## 개발용 Docker 네트워크 보장
|
||||
@echo "Ensuring Docker networks..."
|
||||
@for network in $(DEV_NETWORKS); do \
|
||||
if ! docker network inspect "$$network" >/dev/null 2>&1; then \
|
||||
echo "Creating Docker network $$network..."; \
|
||||
docker network create "$$network"; \
|
||||
else \
|
||||
echo "Docker network $$network already exists."; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
ensure-infra: ensure-networks ## 인프라 스택 실행 상태 보장
|
||||
@echo "Ensuring Infra stack..."
|
||||
@missing=0; \
|
||||
for container in $(INFRA_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
echo "Starting missing Infra stack containers in daemon mode..."; \
|
||||
docker compose -f $(COMPOSE_INFRA) up -d; \
|
||||
else \
|
||||
echo "Infra stack is already running."; \
|
||||
fi
|
||||
|
||||
ensure-ory: ensure-networks render-ory-config ## Ory 스택 실행 상태 보장
|
||||
@echo "Ensuring Ory stack..."
|
||||
@missing=0; \
|
||||
for container in $(ORY_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||
else \
|
||||
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
|
||||
fi
|
||||
|
||||
ensure-restore-containers: ## 복구 대상 저장소 컨테이너 실행 상태 보장
|
||||
@echo "Ensuring restore target containers..."
|
||||
@if [ "$(CONFIRM_RESTORE)" != "baron-sso" ]; then \
|
||||
echo "Skipping restore target container startup until CONFIRM_RESTORE=baron-sso is provided."; \
|
||||
exit 0; \
|
||||
fi
|
||||
@$(MAKE) --no-print-directory ensure-networks
|
||||
@ensure_restore_container() { \
|
||||
container="$$1"; \
|
||||
compose_file="$$2"; \
|
||||
compose_service="$$3"; \
|
||||
if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \
|
||||
echo "Restore target container $$container is already running."; \
|
||||
return 0; \
|
||||
fi; \
|
||||
if docker inspect "$$container" >/dev/null 2>&1; then \
|
||||
echo "Starting stopped restore target container $$container..."; \
|
||||
docker start "$$container"; \
|
||||
else \
|
||||
echo "Creating restore target container $$container via $$compose_file service $$compose_service..."; \
|
||||
docker compose -f "$$compose_file" up -d "$$compose_service"; \
|
||||
fi; \
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \
|
||||
return 0; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "ERROR: restore target container $$container did not reach running state." >&2; \
|
||||
return 1; \
|
||||
}; \
|
||||
services="$(RESTORE_SERVICES)"; \
|
||||
if [ -z "$$services" ] || [ "$$services" = "all" ]; then \
|
||||
services="postgres ory-postgres clickhouse ory-clickhouse config"; \
|
||||
else \
|
||||
services="$$(printf '%s' "$$services" | tr ',' ' ')"; \
|
||||
fi; \
|
||||
for service in $$services; do \
|
||||
case "$$service" in \
|
||||
postgres) ensure_restore_container baron_postgres compose.infra.yaml postgres ;; \
|
||||
ory-postgres) ensure_restore_container ory_postgres compose.ory.yaml postgres ;; \
|
||||
clickhouse) ensure_restore_container baron_clickhouse compose.infra.yaml clickhouse ;; \
|
||||
ory-clickhouse) ensure_restore_container ory_clickhouse compose.ory.yaml ory_clickhouse ;; \
|
||||
config) ;; \
|
||||
*) echo "ERROR: unknown restore service: $$service" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
done
|
||||
|
||||
up-dev: ensure-infra ensure-ory ## 개발 기본 스택 준비
|
||||
@echo "Dev stack is up (infra + ory)."
|
||||
|
||||
up-front-dev: up-infra up-ory up-backend
|
||||
up-front-dev: up-infra up-ory up-backend ## 프론트 개발용 의존 스택 준비
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
dev: up-dev ## 개발 앱 컨테이너를 포그라운드로 실행
|
||||
@echo "Starting development app containers in foreground attach mode..."
|
||||
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
dev-debug: up-dev ## 디버그 로그로 개발 앱 컨테이너 실행
|
||||
@echo "Starting development app containers in foreground attach debug mode..."
|
||||
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down:
|
||||
down: ## 전체 로컬 스택 중지
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
down-app:
|
||||
drop: ## 로컬 스택 컨테이너, 볼륨, 로컬 이미지 제거
|
||||
@echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..."
|
||||
-docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local
|
||||
@echo "Removing any remaining fixed-name Baron SSO containers..."
|
||||
@for container in $(DROP_CONTAINERS); do \
|
||||
docker rm -f "$$container" >/dev/null 2>&1 || true; \
|
||||
done
|
||||
@echo "Drop complete. External Docker networks are preserved."
|
||||
|
||||
down-app: ## 앱 스택 중지
|
||||
@echo "Stopping App stack..."
|
||||
docker compose -f $(COMPOSE_APP) down
|
||||
|
||||
down-backend:
|
||||
down-backend: ## 백엔드 컨테이너 중지
|
||||
@echo "Stopping Backend only..."
|
||||
docker compose -f $(COMPOSE_APP) stop backend
|
||||
|
||||
down-infra:
|
||||
down-infra: ## 인프라 스택 중지
|
||||
@echo "Stopping Infra stack..."
|
||||
docker compose -f $(COMPOSE_INFRA) down
|
||||
|
||||
down-ory:
|
||||
down-ory: ## Ory 스택 중지
|
||||
@echo "Stopping Ory stack..."
|
||||
docker compose -f $(COMPOSE_ORY) down
|
||||
|
||||
# --- 유틸리티 ---
|
||||
# 인프라 상태 확인
|
||||
check-infra:
|
||||
check-infra: ## 인프라 헬스 상태 확인
|
||||
@echo "Checking infra status..."
|
||||
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
|
||||
echo "Error: PostgreSQL is not running or not healthy."; \
|
||||
@@ -94,66 +300,132 @@ check-infra:
|
||||
echo "PostgreSQL is healthy."; \
|
||||
fi
|
||||
|
||||
ps:
|
||||
ps: ## 전체 Compose 컨테이너 상태 조회
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
|
||||
|
||||
logs-infra:
|
||||
logs-infra: ## 인프라 스택 로그 팔로우
|
||||
docker compose -f $(COMPOSE_INFRA) logs -f
|
||||
|
||||
logs-ory:
|
||||
logs-ory: ## Ory 스택 로그 팔로우
|
||||
docker compose -f $(COMPOSE_ORY) logs -f
|
||||
|
||||
logs-app:
|
||||
logs-app: ## 앱 스택 로그 팔로우
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
# --- 백업/복구 ---
|
||||
backup-tools-build: ## 백업 도구 Docker 이미지 빌드
|
||||
docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) .
|
||||
|
||||
ifeq ($(BACKUP_USE_DOCKER),true)
|
||||
dump: backup-tools-build ## 백업 덤프 생성
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_DATASET="$(DUMP_DATASET)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
|
||||
|
||||
filter-personnel-dump: backup-tools-build ## 전체 백업에서 personnel dataset 백업 추출
|
||||
$(MAKE) --no-print-directory ensure-restore-containers RESTORE_SERVICES="$(FILTER_SERVICES)" CONFIRM_RESTORE=baron-sso
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" OUTPUT_BACKUP="$(OUTPUT_BACKUP)" FILTER_SERVICES="$(FILTER_SERVICES)" scripts/backup/filter_personnel_dump.sh'
|
||||
|
||||
restore: backup-tools-build ensure-restore-containers ## 백업 덤프 복구
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
|
||||
|
||||
dump-verify: backup-tools-build ## 백업 덤프 검증
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh'
|
||||
|
||||
restore-verify: backup-tools-build ## 복구 결과 검증
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh'
|
||||
|
||||
dump-list: backup-tools-build ## 사용 가능한 백업 덤프 목록 조회
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh'
|
||||
|
||||
restore-plan: backup-tools-build ## 복구 실행 계획 출력
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
|
||||
|
||||
upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드
|
||||
$(BACKUP_DOCKER_RUN) bash -lc '$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
|
||||
|
||||
works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신
|
||||
WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh
|
||||
else
|
||||
dump: ## 백업 덤프 생성
|
||||
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_DATASET="$(DUMP_DATASET)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
|
||||
|
||||
filter-personnel-dump: ## 전체 백업에서 personnel dataset 백업 추출
|
||||
$(MAKE) --no-print-directory ensure-restore-containers RESTORE_SERVICES="$(FILTER_SERVICES)" CONFIRM_RESTORE=baron-sso
|
||||
BACKUP="$(BACKUP)" OUTPUT_BACKUP="$(OUTPUT_BACKUP)" FILTER_SERVICES="$(FILTER_SERVICES)" scripts/backup/filter_personnel_dump.sh
|
||||
|
||||
restore: ensure-restore-containers ## 백업 덤프 복구
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
|
||||
|
||||
dump-verify: ## 백업 덤프 검증
|
||||
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
|
||||
|
||||
restore-verify: ## 복구 결과 검증
|
||||
BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh
|
||||
|
||||
dump-list: ## 사용 가능한 백업 덤프 목록 조회
|
||||
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
|
||||
|
||||
restore-plan: ## 복구 실행 계획 출력
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
|
||||
|
||||
upload-cloud: ## 백업 덤프 클라우드 업로드
|
||||
$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
|
||||
|
||||
works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신
|
||||
WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh
|
||||
endif
|
||||
|
||||
dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드
|
||||
|
||||
docker-image-upload-works: ## Docker 이미지를 WORKS Shared Drive archive로 업로드
|
||||
WORKS_DOCKER_COMMIT_CONTAINER="$(WORKS_DOCKER_COMMIT_CONTAINER)" DOCKER_IMAGE_REF="$(DOCKER_IMAGE_REF)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/upload_works_drive.sh
|
||||
|
||||
docker-image-verify-works: ## WORKS Shared Drive Docker image archive 검증
|
||||
WORKS_DOCKER_VERIFY_LOAD="$(WORKS_DOCKER_VERIFY_LOAD)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/verify_archive.sh "$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)"
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
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
|
||||
.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-orgfront-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
|
||||
code-check: code-check-lint code-check-test-jobs ## 로컬 CI 상당 코드 검사 실행
|
||||
@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-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:
|
||||
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-devfront-tests \
|
||||
code-check-orgfront-tests
|
||||
|
||||
code-check-i18n:
|
||||
code-check-i18n: ## 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:
|
||||
code-check-i18n-values: ## i18n 번역 값 품질 검사
|
||||
@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:
|
||||
code-check-go-lint: ## Go 포맷과 린트 검사
|
||||
@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; \
|
||||
@@ -169,38 +441,71 @@ code-check-go-lint:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
code-check-sync-userfront-locales:
|
||||
code-check-sync-userfront-locales: ## UserFront 로케일 동기화 검사
|
||||
@echo "==> sync userfront locales"
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
code-check-userfront-install:
|
||||
code-check-userfront-install: ## UserFront 의존성 설치
|
||||
@echo "==> install userfront dependencies"
|
||||
cd userfront && flutter pub get
|
||||
@if command -v flutter >/dev/null 2>&1; then \
|
||||
cd userfront && flutter pub get; \
|
||||
else \
|
||||
echo "WARNING: flutter not found, skipping userfront dependencies install."; \
|
||||
fi
|
||||
|
||||
code-check-userfront-lint:
|
||||
code-check-userfront-lint: ## UserFront 포맷과 analyze 검사
|
||||
@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
|
||||
@if command -v dart >/dev/null 2>&1; then \
|
||||
cd userfront && dart format --output=none --set-exit-if-changed lib test; \
|
||||
else \
|
||||
echo "WARNING: dart not found, skipping userfront format check."; \
|
||||
fi
|
||||
@if command -v flutter >/dev/null 2>&1; then \
|
||||
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos; \
|
||||
else \
|
||||
echo "WARNING: flutter not found, skipping userfront analyze."; \
|
||||
fi
|
||||
|
||||
code-check-front-lint:
|
||||
code-check-front-lint: ## 프론트엔드 Biome 린트와 포맷 검사
|
||||
@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
|
||||
@if [ -d adminfront/node_modules ]; then \
|
||||
echo "adminfront/node_modules already present; skipping pnpm install."; \
|
||||
else \
|
||||
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts; \
|
||||
fi
|
||||
cd adminfront && npx biome lint .
|
||||
cd adminfront && npx biome format .
|
||||
@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
|
||||
@if [ -d devfront/node_modules ]; then \
|
||||
echo "devfront/node_modules already present; skipping npm install."; \
|
||||
else \
|
||||
cd devfront && npm ci --ignore-scripts; \
|
||||
fi
|
||||
cd devfront && npx biome lint .
|
||||
cd devfront && npx biome format .
|
||||
@echo "==> orgfront biome lint/format check"
|
||||
rm -rf orgfront/playwright-report orgfront/test-results
|
||||
@if [ -d orgfront/node_modules ]; then \
|
||||
echo "orgfront/node_modules already present; skipping npm install."; \
|
||||
else \
|
||||
cd orgfront && npm ci --ignore-scripts; \
|
||||
fi
|
||||
cd orgfront && ./node_modules/@biomejs/biome/bin/biome lint .
|
||||
cd orgfront && ./node_modules/@biomejs/biome/bin/biome format .
|
||||
|
||||
code-check-backend-tests:
|
||||
code-check-backend-tests: ## 백엔드 Go 테스트 실행
|
||||
@echo "==> backend tests"
|
||||
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
|
||||
|
||||
code-check-userfront-tests:
|
||||
code-check-userfront-tests: ## UserFront Flutter 테스트 실행
|
||||
@echo "==> userfront tests (isolated workspace)"
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
|
||||
@if ! command -v flutter >/dev/null 2>&1; then \
|
||||
echo "WARNING: flutter not found, skipping userfront tests."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
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/"; \
|
||||
@@ -220,16 +525,23 @@ code-check-userfront-tests:
|
||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
|
||||
|
||||
code-check-adminfront-tests:
|
||||
code-check-adminfront-tests: ## AdminFront 테스트 실행
|
||||
@echo "==> adminfront tests"
|
||||
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
code-check-devfront-tests:
|
||||
code-check-devfront-tests: ## DevFront 테스트 실행
|
||||
@echo "==> devfront tests"
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npm ci) || status=$$?; \
|
||||
preview_pattern='[v]ite preview --host 127.0.0.1 --strictPort --port 4174'; \
|
||||
pkill -f "$$preview_pattern" >/dev/null 2>&1 || true; \
|
||||
trap 'pkill -f "$$preview_pattern" >/dev/null 2>&1 || true' EXIT INT TERM; \
|
||||
if [ -d devfront/node_modules ]; then \
|
||||
echo "devfront/node_modules already present; skipping npm install."; \
|
||||
else \
|
||||
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
@@ -240,11 +552,31 @@ code-check-devfront-tests:
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-userfront-e2e-tests:
|
||||
code-check-orgfront-tests: ## OrgFront 테스트 실행
|
||||
@echo "==> orgfront tests"
|
||||
@mkdir -p reports/orgfront
|
||||
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
|
||||
@status=0; \
|
||||
(cd orgfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \
|
||||
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-userfront-e2e-tests: ## UserFront WASM E2E 테스트 실행
|
||||
@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)"; \
|
||||
@if ! command -v flutter >/dev/null 2>&1; then \
|
||||
echo "WARNING: flutter not found, skipping userfront e2e tests."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
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/"; \
|
||||
@@ -277,7 +609,7 @@ code-check-userfront-e2e-tests:
|
||||
(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=$$?; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || 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();});")"; \
|
||||
|
||||
338
README.md
338
README.md
@@ -1,5 +1,11 @@
|
||||
# Baron SSO
|
||||
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/src/branch/dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev)
|
||||
|
||||
badge는 `Code Check`가 `badges` 브랜치의 `latest/`와 `dev/<commit-sha>/`에 발행합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 패키지별 `*-vitest-coverage-report` artifact에서 확인할 수 있습니다.
|
||||
|
||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||
|
||||
## 📂 프로젝트 구조 (Project Structure)
|
||||
@@ -34,6 +40,20 @@ baron_sso/
|
||||
* AdminFront: 사용자 관리 등 Admin 기능
|
||||
* DevFront: RP 관리 등 개발자 기능
|
||||
|
||||
## 개발 실행 정책
|
||||
|
||||
`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/<app>` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist`를 `serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다.
|
||||
|
||||
현재 개발 포트는 다음과 같습니다.
|
||||
|
||||
- AdminFront: `http://localhost:5173`
|
||||
- DevFront: `http://localhost:5174`
|
||||
- OrgFront: `http://localhost:5175`
|
||||
|
||||
자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다.
|
||||
|
||||
로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다.
|
||||
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
@@ -66,7 +86,7 @@ flowchart
|
||||
```
|
||||
|
||||
### 1. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Language**: Go 1.26.2+
|
||||
- **Framework**: Fiber v2.25+
|
||||
- **Database**:
|
||||
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
|
||||
@@ -95,6 +115,99 @@ flowchart
|
||||
- RP 등록 및 관리
|
||||
- RP별 Consent 관리
|
||||
|
||||
## 관리 데이터 Export/Import 정책
|
||||
|
||||
AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 검토하고 재반입할 수 있는 흐름을 기준으로 설계합니다. 기본 원칙은 내부 UUID를 불필요하게 노출하지 않고, 사람이 이해하기 쉬운 `slug`와 이름을 우선 사용하는 것입니다.
|
||||
|
||||
### 공통 원칙
|
||||
- CSV는 Excel 호환을 위해 UTF-8 BOM을 포함해 내려받습니다.
|
||||
- 기본 export는 시스템 내부 ID를 제외합니다.
|
||||
- 같은 데이터를 정확히 재동기화해야 하는 운영 작업에서는 `includeIds=true` 옵션으로 내부 ID 컬럼을 포함할 수 있습니다.
|
||||
- import는 preview/검토 단계를 거친 뒤 실행하는 것을 기본으로 합니다.
|
||||
- 기존 데이터와 충돌 가능성이 있는 row는 자동 적용하지 않고 관리자 선택 또는 확인 상태로 표시합니다.
|
||||
- 삭제는 export/import로 암묵 처리하지 않습니다. 삭제가 필요하면 별도 삭제 기능을 사용합니다.
|
||||
|
||||
### Tenant Export
|
||||
- 기본 컬럼은 운영자가 다시 import하기 쉬운 형태를 유지합니다.
|
||||
- `includeIds=false`가 기본이며, 이 경우 내부 `tenant_id`는 제외합니다.
|
||||
- `includeIds=true`를 사용하면 기존 테넌트 update 또는 staging/production 간 매핑 확인에 필요한 ID를 포함합니다.
|
||||
- 주요 의미:
|
||||
- `tenant_id`: 내부 UUID. 기본 export에서는 제외됩니다.
|
||||
- `name`: 테넌트 표시 이름입니다.
|
||||
- `type`: `PERSONAL`, `COMPANY`, `COMPANY_GROUP`, `USER_GROUP` 중 하나입니다.
|
||||
- `parent_tenant_id`: 상위 테넌트 내부 ID입니다.
|
||||
- `parent_tenant_slug`: 상위 테넌트를 slug로 연결할 때 사용합니다.
|
||||
- `slug`: 운영상 사람이 다루는 테넌트 식별자입니다.
|
||||
- `memo`: 설명 또는 비고입니다.
|
||||
- `email_domain`: 테넌트에 연결된 이메일 도메인입니다. 여러 도메인은 `;`, `,`, 줄바꿈으로 구분할 수 있습니다.
|
||||
|
||||
### Tenant Import
|
||||
- 필수 컬럼은 `name`, `type`, `slug`입니다.
|
||||
- 허용되는 header alias:
|
||||
- `tenant_id`: `id`, `tenantid`, `tenant_id`
|
||||
- `parent_tenant_id`: `parentid`, `parent_id`, `parenttenantid`, `parent_tenant_id`
|
||||
- `parent_tenant_slug`: `parenttenantslug`, `parent_tenant_slug`
|
||||
- `memo`: `memo`, `description`
|
||||
- `email_domain`: `email-domain`, `emaildomain`, `email_domain`, `domain`, `domains`
|
||||
- `tenant_id`가 있고 기존 테넌트가 있으면 update 대상으로 봅니다.
|
||||
- `tenant_id`가 없으면 `slug` 기준으로 기존 테넌트를 찾고, 없으면 신규 생성 후보로 봅니다.
|
||||
- `parent_tenant_slug`가 같은 import 파일 안에 있으면 부모 row를 먼저 처리하도록 정렬합니다.
|
||||
- import preview는 이름/slug 유사도 기반 후보를 보여주며, 관리자가 기존 테넌트 사용, 신규 생성, skip 중 선택할 수 있어야 합니다.
|
||||
- 외부 시스템에서 가져온 `tenant_id`처럼 현재 DB에 없는 ID는 충돌로 표시하고, 관리자가 새 slug 또는 기존 테넌트 매핑을 결정해야 합니다.
|
||||
|
||||
### User Export
|
||||
- 기본 컬럼은 `Email`, `Name`, `Phone`, `Status`, `tenant_slug`, `Position`, `JobTitle`, `CreatedAt`입니다.
|
||||
- `includeIds=true`이면 `user_id`, `tenant_id`를 함께 포함합니다.
|
||||
- 사용자 role은 export 기본 컬럼에서 제외합니다. role은 일괄 변경의 실수 위험이 크므로 명시적 관리 화면 또는 별도 정책으로 다룹니다.
|
||||
- 사용자 metadata는 `Meta:<key>` 컬럼으로 뒤에 추가됩니다.
|
||||
- `includeIds=false`일 때는 `id`, `user_id`, `tenant_id`, `tenantid` 성격의 metadata key를 export에서 제외합니다.
|
||||
- tenant admin의 export는 관리 가능한 테넌트 범위로 제한됩니다.
|
||||
|
||||
### User Import
|
||||
- 사용자 CSV의 기본 컬럼은 `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`입니다.
|
||||
- `email`과 `name`은 CSV parsing 단계의 필수값입니다.
|
||||
- backend 생성 단계에서는 `tenantSlug`도 필수입니다.
|
||||
- `tenant`, `tenant_slug`, `companyCode` header는 사용자 소속 테넌트 slug로 매핑됩니다.
|
||||
- `tenant_id`, `tenant_name`, `tenant_type`, `parent_tenant_id`, `parent_tenant_slug`, `parent_tenant_name`, `tenant_memo`, `email_domain` 컬럼이 있으면 사용자 import 과정에서 필요한 테넌트 생성/매핑 preview에 사용합니다.
|
||||
- 위 기본 컬럼에 속하지 않는 컬럼은 사용자 metadata로 들어갑니다.
|
||||
- 테넌트에 `userSchema`가 있으면 import 중 metadata required/validation/loginId 규칙을 적용합니다.
|
||||
- 테넌트 schema에서 `isLoginId`로 지정된 metadata 값은 custom login ID로 동기화하며, 이메일/전화번호/예약어와 충돌하지 않아야 합니다.
|
||||
|
||||
### 한맥가족 User Import Email 정책
|
||||
- 전체 시스템에서 `users.email`은 unique입니다.
|
||||
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
|
||||
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
|
||||
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
||||
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
||||
- 이름 기반 local-part 기본 규칙은 `이름 부분 초성 + 성 로마자`입니다.
|
||||
- 예: `한치영` -> `치영`의 초성 `c + y` + 성 `han` -> `cyhan`
|
||||
- 이미 `cyhan`, `cyhan1`이 있으면 다음 후보인 `cyhan2`를 제안합니다.
|
||||
- 외부 로마자화 패키지는 backend 의존성으로 추가하지 않고, 내부 한글 음절 분해와 성씨/초성 매핑을 사용합니다.
|
||||
- import preview의 row 상태:
|
||||
- `valid`: unique와 이름 기반 권장 규칙을 모두 만족합니다.
|
||||
- `suggested`: 도메인만 있거나 suffix 제안이 필요한 row입니다.
|
||||
- `needsReview`: 이름 매핑이 애매해 관리자가 직접 확인해야 합니다.
|
||||
- `ruleMismatch`: 최종 local-part가 `이름 이니셜 + 성 + 숫자 suffix` 규칙과 다릅니다. 예외 진행은 가능하지만 관리자에게 표시해야 합니다.
|
||||
- `blockingError`: local-part 중복, email 형식 오류, 필수값 누락처럼 생성을 차단해야 하는 상태입니다.
|
||||
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
||||
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
|
||||
|
||||
### User Status 정책
|
||||
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
|
||||
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
|
||||
| `suspended` | 정지 | 불가 | suspend | 노출 |
|
||||
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
|
||||
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
|
||||
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
|
||||
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
|
||||
|
||||
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
||||
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
||||
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
|
||||
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
||||
|
||||
|
||||
### 4. 주요 시나리오 (Core Scenarios)
|
||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||
@@ -279,6 +392,61 @@ flowchart TD
|
||||
|
||||
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
|
||||
|
||||
### SSOT 및 Redis Cache 전략
|
||||
|
||||
Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Redis는 Ory 원장 데이터의 성능 cache/mirror로만 사용합니다.
|
||||
|
||||
Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API를 adminfront, orgfront, userfront, 외부 API에 제공합니다.
|
||||
|
||||
#### 데이터별 원본 위치
|
||||
|
||||
| 데이터 | SSOT | 보조 저장소/캐시 | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. |
|
||||
| 로그인 식별자 | Ory Kratos traits | Redis identity mirror/index | Kratos가 인증 식별자의 원장입니다. |
|
||||
| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror | 인증/profile 계산에 필요한 identity 값은 Kratos 기준으로 유지합니다. |
|
||||
| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Ory Kratos traits/state 또는 별도 명시 원장 | Redis mirror/cache | Backend DB `users`를 사용자 read model로 사용하지 않습니다. |
|
||||
| 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. |
|
||||
| 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. |
|
||||
| OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. |
|
||||
| RP별 사용자 custom claim 값 | Backend read model `rp_user_metadata` | ID token/userinfo claim assembly | Ory에 저장되지 않는 RP 범위 데이터입니다. Kratos traits나 claim output을 SSOT로 취급하지 않습니다. |
|
||||
| 전역 사용자 custom claim 값 | Backend read model `users.metadata.global_custom_claims` | ID token claim assembly | Ory에 저장되지 않는 운영 범위 custom 값입니다. |
|
||||
| WORKS Mobile mapping/outbox/job 상태 | PostgreSQL `worksmobile_*` | WORKS API 비교 응답 cache 가능 | 외부 SaaS 연동 상태이며 identity 원장이 아닙니다. |
|
||||
| 감사 로그/사용량 | ClickHouse, Oathkeeper/Ory 로그 | 화면별 summary cache 가능 | command와 보안 이벤트의 감사 원장입니다. |
|
||||
| Headless JWKS 검증 상태 | Redis `headless:jwks:*` cache | DevFront 상태 카드 | RP public key 문서 자체는 외부 `jwksUri`가 원본입니다. |
|
||||
| 로그인 코드, pending login, verification token | Redis short-lived key | 없음 | 만료 가능한 휘발성 상태입니다. 백업/복구 대상이 아닙니다. |
|
||||
|
||||
#### SSOT 보장 원칙
|
||||
|
||||
1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다.
|
||||
2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다.
|
||||
3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다.
|
||||
4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다.
|
||||
5. Backend DB `users`나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자 데이터를 근거로 정상 목록을 만들지 않습니다.
|
||||
6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다.
|
||||
7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다.
|
||||
|
||||
#### Redis 사용 원칙
|
||||
|
||||
Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유실은 장애지만 데이터 유실 사고로 보지 않고, 원장 재조회와 refresh로 재수렴해야 합니다.
|
||||
|
||||
| Redis 데이터 | 역할 | TTL/보존 정책 | 장애 시 처리 |
|
||||
| --- | --- | --- | --- |
|
||||
| `identity:mirror:{identityID}` | Kratos identity summary 단건 cache | 장기 mirror. refresh 상태와 함께 운영 | Kratos `GetIdentity` fallback 후 write-through |
|
||||
| `identity:index:*` | Backend cursor API용 identity 목록/검색 index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh |
|
||||
| `identity:mirror:state` | mirror 상태, count, last error | 영구 상태 key | adminfront에서 경고 표시 |
|
||||
| `headless:jwks:*` | RP headless login JWKS cache | JWKS TTL과 prefetch 정책 | kid miss/검증 실패/TTL 만료 시 재조회 |
|
||||
| login/verification/pending 계열 key | 인증 흐름의 단기 상태 | 짧은 TTL 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 |
|
||||
| 일반 API response cache | 선택적 성능 cache | 짧은 TTL, invalidation 우선 | miss 시 Backend DB 또는 Ory 원장 조회 |
|
||||
|
||||
운영 Redis 설정은 `maxmemory`와 `maxmemory_policy`가 명시되어야 합니다. identity mirror처럼 재수렴 가능한 데이터와 pending login처럼 사용자 흐름에 영향을 주는 단기 key가 같은 Redis를 공유하므로, eviction 발생 여부와 TTL 없는 key 증가를 운영 화면에서 볼 수 있어야 합니다.
|
||||
|
||||
#### Redis 모니터링 계획
|
||||
|
||||
Redis 적정 설정 판단에 필요한 운영 지표를 adminfront에 노출하는 후속 작업은 이슈 [#1046](https://gitea.hmac.kr/baron/baron-sso/issues/1046)으로 분리했습니다.
|
||||
|
||||
표시 대상은 Redis 연결/버전/uptime, `used_memory`, `maxmemory`, `maxmemory_policy`, keyspace hit/miss, expired/evicted keys, prefix별 key count, TTL 분포, `identity:mirror:state`, headless JWKS cache failure 요약입니다. 이 화면은 `super_admin` 전용으로 두고, Redis key value 자체는 노출하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기 (Getting Started)
|
||||
@@ -319,11 +487,13 @@ USERFRONT_URL=https://sso.example.com
|
||||
- `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`)
|
||||
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/auth/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`
|
||||
- `KRATOS_ALLOWED_RETURN_URLS_JSON`: stage/prod에서 권장하는 전체 허용 return URL 목록
|
||||
- 공개 도메인, `/ko`, `/en`, `/auth/callback`, `/ko/auth/callback`, `/en/auth/callback`, 각 front callback을 포함해야 합니다.
|
||||
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
||||
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
||||
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
||||
@@ -358,7 +528,7 @@ USERFRONT_URL=https://sso.example.com
|
||||
```bash
|
||||
make validate-auth-config
|
||||
```
|
||||
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
|
||||
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
|
||||
|
||||
### 전체 스택 실행 (Running the Stack)
|
||||
|
||||
@@ -400,7 +570,7 @@ make validate-auth-config
|
||||
make verify-auth-config
|
||||
```
|
||||
|
||||
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
|
||||
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
|
||||
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
|
||||
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
|
||||
|
||||
@@ -415,8 +585,8 @@ 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 --env-file .env --env-file config/.generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||
docker compose --env-file .env --env-file config/.generated/auth-config.env -f docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
|
||||
@@ -426,6 +596,155 @@ docker compose --env-file .env --env-file .generated/auth-config.env -f docker-c
|
||||
- **Hydra Public**: http://localhost:4444
|
||||
- **Kratos UI (UserFront)**: http://localhost:5000
|
||||
|
||||
### 전체 백업/복구
|
||||
|
||||
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
|
||||
|
||||
#### 백업 실행
|
||||
```bash
|
||||
# 전체 백업
|
||||
make dump
|
||||
|
||||
# 출력 위치를 직접 지정
|
||||
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 일부 서비스만 백업
|
||||
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
|
||||
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
|
||||
|
||||
# 생성된 백업 검증
|
||||
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# WORKS Drive로 외부 분산 저장
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
|
||||
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 로컬 백업 목록
|
||||
make dump-list
|
||||
```
|
||||
|
||||
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
|
||||
|
||||
| 값 | 대상 |
|
||||
| --- | --- |
|
||||
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
|
||||
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
|
||||
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
|
||||
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
|
||||
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
|
||||
|
||||
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
checksums.sha256
|
||||
postgres/
|
||||
clickhouse/
|
||||
config/
|
||||
reports/
|
||||
```
|
||||
|
||||
#### WORKS Drive 외부 업로드
|
||||
|
||||
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
|
||||
|
||||
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
|
||||
|
||||
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
|
||||
|
||||
```bash
|
||||
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
|
||||
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 또는 같은 BACKUP 경로로 연속 실행
|
||||
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 실제 업로드 전 endpoint와 target만 확인
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
|
||||
|
||||
# 예외적으로 호스트 도구로 직접 실행
|
||||
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
|
||||
```
|
||||
|
||||
주요 변수:
|
||||
|
||||
| 변수 | 설명 |
|
||||
| --- | --- |
|
||||
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
|
||||
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
|
||||
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
|
||||
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
|
||||
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
|
||||
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
|
||||
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
|
||||
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
|
||||
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
|
||||
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
|
||||
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
|
||||
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
|
||||
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
|
||||
|
||||
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
|
||||
|
||||
#### 복구 계획과 복구 실행
|
||||
```bash
|
||||
# 복구 전 계획 확인
|
||||
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# 복구 실행
|
||||
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# .tar.zst archive를 직접 복구 입력으로 사용
|
||||
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
|
||||
RESTORE_SERVICES=all \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# report 경로를 명시
|
||||
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
CONFIRM_RESTORE=baron-sso \
|
||||
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
|
||||
|
||||
# 복구 후 기본 검증
|
||||
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
```
|
||||
|
||||
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
|
||||
|
||||
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
|
||||
|
||||
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
|
||||
|
||||
#### 백업/복구 범위
|
||||
|
||||
필수 백업 대상:
|
||||
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
|
||||
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
|
||||
- Baron ClickHouse: 감사 로그와 RP usage event
|
||||
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
|
||||
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
|
||||
|
||||
기본 제외 대상:
|
||||
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
|
||||
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
|
||||
- coverage, reports, test-results 같은 로컬 개발 산출물
|
||||
|
||||
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
|
||||
|
||||
### MCP 서버 (Hydra/Kratos/Keto)
|
||||
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
||||
@@ -463,11 +782,12 @@ 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`로 로드합니다.
|
||||
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
|
||||
- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml`은 `ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
|
||||
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 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`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 `root locales`와 `common/locales`의 코드-키-로케일 동기화 상태를 함께 점검합니다.
|
||||
|
||||
## 🧪 Code Check CI
|
||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||
|
||||
@@ -14,7 +14,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
||||
- Descope SDK Integration (Enchanted Link, Magic Link)
|
||||
|
||||
### 2. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Language**: Go 1.26.2+
|
||||
- **Framework**: Fiber v2.25+
|
||||
- **Database**:
|
||||
- **ClickHouse**: Audit Logs (High performance ingestion)
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
FROM node:lts
|
||||
FROM node:lts AS deps
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /workspace
|
||||
|
||||
# 패키지 정보 복사 및 의존성 설치
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
ENV CI=true
|
||||
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY adminfront ./adminfront
|
||||
|
||||
ARG VITE_ADMIN_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ARG ORGFRONT_URL
|
||||
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
ENV ORGFRONT_URL=$ORGFRONT_URL
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
FROM deps AS dev
|
||||
|
||||
WORKDIR /workspace/adminfront
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
|
||||
FROM deps AS build
|
||||
|
||||
WORKDIR /workspace/adminfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5173
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/adminfront/dist ./dist
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
|
||||
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
BIN
adminfront/Trace-20260615T113806.json.gz
Normal file
Binary file not shown.
@@ -1,30 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useEnumInitializers": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"playwright-report"
|
||||
]
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
134
adminfront/e2e-evidence/tenant-profile-performance-local.json
Normal file
134
adminfront/e2e-evidence/tenant-profile-performance-local.json
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"metric": "tenant-profile-local-performance",
|
||||
"tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"actualApiBaseUrl": "http://localhost:5173/api",
|
||||
"measuredAt": "2026-06-16T23:45:00.441Z",
|
||||
"browser": "chromium",
|
||||
"samples": [
|
||||
{
|
||||
"sample": 1,
|
||||
"configFieldsVisibleMs": 424,
|
||||
"networkIdleMs": 862,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 134
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 184
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 2,
|
||||
"configFieldsVisibleMs": 376,
|
||||
"networkIdleMs": 751,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 20
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 133
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 3,
|
||||
"configFieldsVisibleMs": 400,
|
||||
"networkIdleMs": 797,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 21
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 156
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 4,
|
||||
"configFieldsVisibleMs": 431,
|
||||
"networkIdleMs": 843,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 25
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 178
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 5,
|
||||
"configFieldsVisibleMs": 380,
|
||||
"networkIdleMs": 758,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 24
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 129
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"configFieldsVisibleMs": {
|
||||
"min": 376,
|
||||
"max": 431,
|
||||
"p50": 400,
|
||||
"p95": 431
|
||||
},
|
||||
"networkIdleMs": {
|
||||
"min": 751,
|
||||
"max": 862,
|
||||
"p50": 797,
|
||||
"p95": 862
|
||||
}
|
||||
},
|
||||
"screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png"
|
||||
}
|
||||
BIN
adminfront/e2e-evidence/tenant-profile-performance-local.png
Normal file
BIN
adminfront/e2e-evidence/tenant-profile-performance-local.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -1,474 +0,0 @@
|
||||
|
||||
> 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개
|
||||
2503
adminfront/package-lock.json
generated
2503
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,52 +13,56 @@
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "npx playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"test": "playwright test",
|
||||
"test:coverage": "vitest run --coverage --bail 1",
|
||||
"test:unit": "vitest run --bail 1",
|
||||
"test:ui": "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-avatar": "^1.1.11",
|
||||
"@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-scroll-area": "^1.2.10",
|
||||
"@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",
|
||||
"@tanstack/react-query-devtools": "^5.66.8",
|
||||
"axios": "^1.7.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"axios": "^1.16.1",
|
||||
"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"
|
||||
"lucide-react": "^1.14.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.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/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { shouldIncludeWebKit } =
|
||||
require("../scripts/playwrightHostDeps.cjs") as {
|
||||
shouldIncludeWebKit: () => boolean;
|
||||
};
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
@@ -7,6 +14,9 @@ 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;
|
||||
const usePreviewServer =
|
||||
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
|
||||
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -49,7 +59,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: chromiumExecutablePath
|
||||
? { executablePath: chromiumExecutablePath }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -57,21 +72,25 @@ export default defineConfig({
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
...(shouldIncludeWebKit()
|
||||
? [
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
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,
|
||||
command: usePreviewServer
|
||||
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
|
||||
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer,
|
||||
timeout: 120 * 1000,
|
||||
timeout: 180 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
3722
adminfront/pnpm-lock.yaml
generated
Normal file
3722
adminfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
7
adminfront/public/LINE_WORKS_Appicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.5556 0H6.44444C2.88528 0 0 2.88528 0 6.44444V23.5556C0 27.1147 2.88528 30 6.44444 30H23.5556C27.1147 30 30 27.1147 30 23.5556V6.44444C30 2.88528 27.1147 0 23.5556 0Z" fill="white"/>
|
||||
<path d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z" fill="#028B3A"/>
|
||||
<path d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z" fill="#88E518"/>
|
||||
<path d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z" fill="#7EE3A1"/>
|
||||
<path d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z" fill="#03C75A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -3,6 +3,19 @@ set -eu
|
||||
|
||||
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_URL:-}" ]; then
|
||||
export VITE_ADMIN_PUBLIC_URL="$ADMINFRONT_URL"
|
||||
fi
|
||||
|
||||
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_CALLBACK_URLS:-}" ]; then
|
||||
first_admin_callback="${ADMINFRONT_CALLBACK_URLS%%,*}"
|
||||
case "$first_admin_callback" in
|
||||
http://*/auth/callback | https://*/auth/callback)
|
||||
export VITE_ADMIN_PUBLIC_URL="${first_admin_callback%/auth/callback}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$app_env" in
|
||||
production|prod|stage|staging)
|
||||
mode="production"
|
||||
@@ -12,14 +25,119 @@ case "$app_env" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = "--print-admin-public-url" ]; then
|
||||
printf '%s\n' "${VITE_ADMIN_PUBLIC_URL:-}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--print-mode" ]; then
|
||||
printf '%s\n' "$mode"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
APP_PACKAGE_NAME="adminfront"
|
||||
|
||||
# Detect workspace root
|
||||
if [ -f "/workspace/pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="/workspace"
|
||||
elif [ -f "../../pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="../.."
|
||||
else
|
||||
WORKSPACE_ROOT=""
|
||||
fi
|
||||
|
||||
# Manage dependencies from the real workspace tree if possible, otherwise use current dir.
|
||||
if [ -n "$WORKSPACE_ROOT" ]; then
|
||||
WORKSPACE_DIR="$WORKSPACE_ROOT"
|
||||
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
|
||||
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="npm ci"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode=""
|
||||
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
|
||||
|
||||
acquire_install_lock() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
lock_mode="flock"
|
||||
exec 9>"$lock_file"
|
||||
flock 9
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode="mkdir"
|
||||
while ! mkdir "$lock_file" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
}
|
||||
|
||||
release_install_lock() {
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [ "$lock_mode" = "flock" ]; then
|
||||
flock -u 9 || true
|
||||
exec 9>&-
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$lock_mode" = "mkdir" ]; then
|
||||
rmdir "$lock_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||
release_install_lock
|
||||
return 0
|
||||
fi
|
||||
|
||||
eval "$INSTALL_CMD"
|
||||
|
||||
mkdir -p node_modules
|
||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||
release_install_lock
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_frontend_dependencies
|
||||
|
||||
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"
|
||||
echo "Running in production mode with custom static server..."
|
||||
export ADMINFRONT_BUILD_OUT_DIR="${ADMINFRONT_BUILD_OUT_DIR:-/tmp/baron-sso-adminfront-dist}"
|
||||
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
|
||||
fi
|
||||
|
||||
echo "Running in development mode..."
|
||||
|
||||
160
adminfront/scripts/serve-prod.mjs
Normal file
160
adminfront/scripts/serve-prod.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const _rootDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const distDir = resolve(
|
||||
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist",
|
||||
);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173);
|
||||
const backendTarget = new URL(
|
||||
process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||
);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function toSafePath(pathname) {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const relative = decoded.replace(/^\/+/, "");
|
||||
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
|
||||
return join(distDir, safe);
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath) {
|
||||
try {
|
||||
return await readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyToBackend(req, res, pathname, search) {
|
||||
const target = new URL(pathname + search, backendTarget);
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value) continue;
|
||||
if (key === "host" || key === "content-length" || key === "connection") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
headers.set(key, value.join(", "));
|
||||
continue;
|
||||
}
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
|
||||
const response = await fetch(target, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? req : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
responseHeaders.delete("transfer-encoding");
|
||||
responseHeaders.delete("connection");
|
||||
|
||||
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.end(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, pathname) {
|
||||
const indexPath = join(distDir, "index.html");
|
||||
const filePath = toSafePath(pathname);
|
||||
|
||||
let resolvedPath = filePath;
|
||||
try {
|
||||
const fileStat = await stat(resolvedPath);
|
||||
if (fileStat.isDirectory()) {
|
||||
resolvedPath = join(resolvedPath, "index.html");
|
||||
}
|
||||
} catch {
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
let body = await tryReadFile(resolvedPath);
|
||||
if (!body) {
|
||||
body = await tryReadFile(indexPath);
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
sendJson(res, 500, { error: "dist_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": getContentType(resolvedPath),
|
||||
"Cache-Control": resolvedPath.endsWith("index.html")
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
await proxyToBackend(req, res, pathname, search);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
await serveStatic(req, res, normalizedPath);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: "internal_server_error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(
|
||||
`Adminfront production server listening on http://${host}:${port}`,
|
||||
);
|
||||
});
|
||||
16
adminfront/seed-tenant.csv
Normal file
16
adminfront/seed-tenant.csv
Normal file
@@ -0,0 +1,16 @@
|
||||
id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync
|
||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,,
|
||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,,
|
||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,,
|
||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,,
|
||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,,
|
||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,,
|
||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,,
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
|
||||
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
|
||||
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
|
||||
e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no
|
||||
ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916,일반회사,COMPANY_GROUP,,commercial,외부 기업회원 루트 테넌트,,,,
|
||||
d19c10f0-0224-4bbb-bf3e-ce579c5338ea,공공기관,COMPANY_GROUP,,public-org,공공기관 기본 루트 테넌트,,,,
|
||||
78accec5-8eba-4324-b8f1-10ab360011fe,교육/학생,COMPANY_GROUP,,edu,교육기관 및 학생 기본 루트 테넌트,,,,
|
||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,개인사용자,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
|
||||
|
@@ -1,11 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
defaultOptions: queryClientDefaultOptions,
|
||||
});
|
||||
|
||||
102
adminfront/src/app/routes.test.tsx
Normal file
102
adminfront/src/app/routes.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { matchRoutes } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAdminAuthRedirectUris } from "../lib/authConfig";
|
||||
import { adminRoutes } from "./routes";
|
||||
|
||||
describe("admin routes", () => {
|
||||
it("accepts the auth callback path generated from the public admin URL", () => {
|
||||
const { redirectUri } = buildAdminAuthRedirectUris(
|
||||
"https://sadmin.hmac.kr",
|
||||
);
|
||||
const callbackPath = new URL(redirectUri).pathname;
|
||||
|
||||
const matches = matchRoutes(adminRoutes, callbackPath);
|
||||
|
||||
expect(callbackPath).toBe("/auth/callback");
|
||||
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("registers the super-admin Ory SSOT system route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/ory-ssot");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/ory-ssot");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("routes global custom claim settings before user detail id matching", async () => {
|
||||
const matches = matchRoutes(adminRoutes, "/users/custom-claims");
|
||||
const leafRoute = matches?.at(-1)?.route;
|
||||
|
||||
expect(leafRoute?.path).toBe("users/custom-claims");
|
||||
expect(await getRouteComponentName(leafRoute)).toBe(
|
||||
"GlobalCustomClaimsPage",
|
||||
);
|
||||
});
|
||||
|
||||
it("code-splits tenant detail profile routes away from the initial admin shell", () => {
|
||||
const matches = matchRoutes(
|
||||
adminRoutes,
|
||||
"/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
);
|
||||
const detailRoute = matches?.find(
|
||||
(match) => match.route.path === "tenants/:tenantId",
|
||||
)?.route;
|
||||
const profileRoute = matches?.at(-1)?.route;
|
||||
|
||||
expect(detailRoute?.element).toBeUndefined();
|
||||
expect(typeof detailRoute?.lazy).toBe("function");
|
||||
expect(profileRoute?.index).toBe(true);
|
||||
expect(profileRoute?.element).toBeUndefined();
|
||||
expect(typeof profileRoute?.lazy).toBe("function");
|
||||
});
|
||||
|
||||
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
|
||||
const rootRoute = adminRoutes.find((route) => route.path === "/");
|
||||
const protectedShellRoute = rootRoute?.children?.[0];
|
||||
|
||||
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
|
||||
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
|
||||
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function getRouteComponentName(route: unknown) {
|
||||
if (
|
||||
typeof route === "object" &&
|
||||
route !== null &&
|
||||
"lazy" in route &&
|
||||
typeof route.lazy === "function"
|
||||
) {
|
||||
const lazyRoute = await route.lazy();
|
||||
if ("Component" in lazyRoute && typeof lazyRoute.Component === "function") {
|
||||
return lazyRoute.Component.name;
|
||||
}
|
||||
if ("element" in lazyRoute) {
|
||||
return getRouteElementName(lazyRoute.element);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof route === "object" && route !== null && "element" in route) {
|
||||
return getRouteElementName(route.element);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRouteElementName(element: unknown) {
|
||||
if (
|
||||
typeof element === "object" &&
|
||||
element !== null &&
|
||||
"type" in element &&
|
||||
typeof element.type === "function"
|
||||
) {
|
||||
return element.type.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,64 +1,197 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
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 AuthGuard from "../features/auth/AuthGuard";
|
||||
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";
|
||||
import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
|
||||
type RouteModule = {
|
||||
default: ComponentType;
|
||||
};
|
||||
|
||||
function lazyDefault(loader: () => Promise<RouteModule>) {
|
||||
return async () => {
|
||||
const module = await loader();
|
||||
return { Component: module.default };
|
||||
};
|
||||
}
|
||||
|
||||
function lazyNamed<TModule, TKey extends keyof TModule>(
|
||||
loader: () => Promise<TModule>,
|
||||
key: TKey,
|
||||
) {
|
||||
return async () => {
|
||||
const module = await loader();
|
||||
return { Component: module[key] as ComponentType };
|
||||
};
|
||||
}
|
||||
|
||||
export const adminRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: ADMIN_AUTH_CALLBACK_PATH,
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/overview/GlobalOverviewPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "audit-logs",
|
||||
lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")),
|
||||
},
|
||||
{
|
||||
path: "auth",
|
||||
lazy: lazyDefault(() => import("../features/auth/AuthPage")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazyDefault(() => import("../features/users/UserListPage")),
|
||||
},
|
||||
{
|
||||
path: "users/custom-claims",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/users/GlobalCustomClaimsPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "users/new",
|
||||
lazy: lazyDefault(() => import("../features/users/UserCreatePage")),
|
||||
},
|
||||
{
|
||||
path: "users/:id",
|
||||
lazy: lazyDefault(() => import("../features/users/UserDetailPage")),
|
||||
},
|
||||
{
|
||||
path: "tenants",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantListPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tenants/new",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantCreatePage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "worksmobile",
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantWorksmobilePage"),
|
||||
"TenantWorksmobilePage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "permissions-direct",
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantFineGrainedPermissionsPage"
|
||||
),
|
||||
"TenantFineGrainedPermissionsPage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantDetailPage"),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantProfilePage"),
|
||||
"TenantProfilePage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "permissions",
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantAdminsAndOwnersTab"
|
||||
),
|
||||
"TenantAdminsAndOwnersTab",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "organization",
|
||||
lazy: lazyDefault(
|
||||
() =>
|
||||
import(
|
||||
"../features/user-groups/routes/TenantUserGroupsTab"
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "schema",
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantSchemaPage"),
|
||||
"TenantSchemaPage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "relations",
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantFineGrainedPermissionsTab"
|
||||
),
|
||||
"TenantFineGrainedPermissionsTab",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
lazy: lazyDefault(
|
||||
() =>
|
||||
import("../features/user-groups/routes/TenantUserGroupsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "api-keys",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/api-keys/ApiKeyListPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "api-keys/new",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/api-keys/ApiKeyCreatePage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "system/ory-ssot",
|
||||
lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")),
|
||||
},
|
||||
{
|
||||
path: "system/data-integrity",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/integrity/DataIntegrityPage"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/auth/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
adminRoutes,
|
||||
// React Router v7 플래그는 Provider에서 적용합니다.
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,8 +30,10 @@ export function RoleGuard({
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
const userRole = normalizeAdminRole(profile?.role);
|
||||
const hasAccess = roles
|
||||
.map((role) => normalizeAdminRole(role))
|
||||
.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
|
||||
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
32
adminfront/src/components/common/LanguageSelector.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||
}));
|
||||
|
||||
describe("LanguageSelector", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates locale without reloading the page", () => {
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
|
||||
render(<LanguageSelector />);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
target: { value: "en" },
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("locale")).toBe("en");
|
||||
expect(
|
||||
dispatchSpy.mock.calls.some(
|
||||
([event]) => event instanceof Event && event.type === "localechange",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
@@ -28,17 +28,33 @@ function resolveLocale(): Locale {
|
||||
function LanguageSelector() {
|
||||
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocale(resolveLocale());
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (next: Locale) => {
|
||||
if (next === locale) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
setLocale(next);
|
||||
window.location.reload();
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
id="admin-language-selector"
|
||||
name="admin-language-selector"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import LocaleRefreshBoundary from "./LocaleRefreshBoundary";
|
||||
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter() {
|
||||
renderCount += 1;
|
||||
return <span>{renderCount}</span>;
|
||||
}
|
||||
|
||||
describe("LocaleRefreshBoundary", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
renderCount = 0;
|
||||
});
|
||||
|
||||
it("re-renders children when locale changes", async () => {
|
||||
render(
|
||||
<LocaleRefreshBoundary>
|
||||
<RenderCounter />
|
||||
</LocaleRefreshBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
window.dispatchEvent(new Event("localechange"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores storage events unrelated to locale changes", async () => {
|
||||
render(
|
||||
<LocaleRefreshBoundary>
|
||||
<RenderCounter />
|
||||
</LocaleRefreshBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: "admin_session",
|
||||
newValue: "token",
|
||||
oldValue: null,
|
||||
storageArea: window.localStorage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
35
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
35
adminfront/src/components/common/LocaleRefreshBoundary.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
|
||||
|
||||
type LocaleRefreshBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
setLocaleVersion((current) => current + 1);
|
||||
};
|
||||
|
||||
const syncLocaleFromStorage = (event: StorageEvent) => {
|
||||
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) {
|
||||
return;
|
||||
}
|
||||
syncLocale();
|
||||
};
|
||||
|
||||
window.addEventListener("localechange", syncLocale);
|
||||
window.addEventListener("storage", syncLocaleFromStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("localechange", syncLocale);
|
||||
window.removeEventListener("storage", syncLocaleFromStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Fragment key={localeVersion}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
export default LocaleRefreshBoundary;
|
||||
187
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
187
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AppLayout from "./AppLayout";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
},
|
||||
signinSilent: vi.fn(async () => undefined),
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
name: "Fetched Admin",
|
||||
email: "fetched@example.com",
|
||||
role: "super_admin",
|
||||
tenantId: "tenant-1",
|
||||
manageableTenants: [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
type: "COMPANY",
|
||||
},
|
||||
{
|
||||
id: "tenant-2",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
type: "ORGANIZATION",
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderLayout(entry = "/users") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
<Route path="users/:id" element={<div>User detail outlet</div>} />
|
||||
<Route
|
||||
path="tenants/:tenantId"
|
||||
element={<div>Tenant outlet</div>}
|
||||
/>
|
||||
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
|
||||
<Route path="login" element={<div>Login outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin AppLayout", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||
authState.signinSilent.mockClear();
|
||||
authState.removeUser.mockClear();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders admin navigation, fetched profile, and outlet content", async () => {
|
||||
renderLayout();
|
||||
|
||||
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||
expect(screen.getByText("Org Chart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ory SSOT System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||
const navigation = screen.getByRole("navigation");
|
||||
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
|
||||
link.textContent?.trim(),
|
||||
);
|
||||
expect(navLabels).toEqual([
|
||||
"Overview",
|
||||
"Tenants",
|
||||
"Org Chart",
|
||||
"Worksmobile",
|
||||
"Ory SSOT System",
|
||||
"Data Integrity",
|
||||
"Users",
|
||||
"권한 부여",
|
||||
"Auth Guard",
|
||||
"API Keys",
|
||||
"Audit Logs",
|
||||
]);
|
||||
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
|
||||
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
|
||||
expect(worksmobileIcon).toHaveAttribute("fill", "none");
|
||||
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
|
||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||
renderLayout();
|
||||
|
||||
const collapseButton = await screen.findByRole("button", {
|
||||
name: "사이드바 접기",
|
||||
});
|
||||
fireEvent.click(collapseButton);
|
||||
|
||||
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "사이드바 펼치기" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||
renderLayout();
|
||||
|
||||
const themeButton = await screen.findByRole("button", {
|
||||
name: "테마 전환",
|
||||
});
|
||||
fireEvent.click(themeButton);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
|
||||
|
||||
const sessionSwitch = screen.getByRole("switch");
|
||||
fireEvent.click(sessionSwitch);
|
||||
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("기술연구팀"));
|
||||
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("내 정보")[0]);
|
||||
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("Logout")[1]);
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
}, 10_000);
|
||||
|
||||
it("attempts silent renewal on user activity when session is near expiry", async () => {
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||
|
||||
renderLayout();
|
||||
await screen.findByText("Fetched Admin");
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
|
||||
expect(authState.signinSilent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
@@ -18,29 +20,147 @@ 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 {
|
||||
AppSidebar,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellSidebarCollapsed,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
writeShellSidebarCollapsed,
|
||||
} from "../../../../common/shell";
|
||||
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
|
||||
const staticNavItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.admin.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.users",
|
||||
labelFallback: "Users",
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.permissions_direct",
|
||||
labelFallback: "권한 부여",
|
||||
to: "/permissions-direct",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.api_keys",
|
||||
labelFallback: "API Keys",
|
||||
to: "/api-keys",
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
expiresAtSec?: number | null;
|
||||
t: ShellTranslator;
|
||||
};
|
||||
|
||||
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
|
||||
}
|
||||
|
||||
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 SessionStatusBadge(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionStatusText(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
|
||||
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-testid="worksmobile-nav-icon"
|
||||
width={Number.isFinite(iconSize) ? iconSize : size}
|
||||
height={Number.isFinite(iconSize) ? iconSize : size}
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
className="shrink-0 text-current"
|
||||
>
|
||||
<path
|
||||
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
@@ -50,47 +170,22 @@ function AppLayout() {
|
||||
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 isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
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({
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||
readShellSidebarCollapsed(false),
|
||||
);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
console.debug("[AppLayout] Fetching profile...");
|
||||
debugLog("[AppLayout] Fetching profile...");
|
||||
try {
|
||||
const data = await fetchMe();
|
||||
console.debug("[AppLayout] Profile fetched successfully:", data.email);
|
||||
debugLog("[AppLayout] Profile fetched successfully:", data.email);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[AppLayout] Failed to fetch profile:", err);
|
||||
@@ -104,71 +199,85 @@ function AppLayout() {
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
const _isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
const effectiveRole = profile?.role;
|
||||
|
||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const isSuperAdmin = isSuperAdminRole(effectiveRole);
|
||||
const _manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const showWorksmobile = canAccessWorksmobile({
|
||||
...profile,
|
||||
role: effectiveRole ?? profile?.role,
|
||||
});
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: false },
|
||||
);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
// Splice optional menus in a standard order
|
||||
items.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
items.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
items.splice(3, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
items.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.ory_ssot",
|
||||
labelFallback: "Ory SSOT System",
|
||||
to: "/system/ory-ssot",
|
||||
icon: Database,
|
||||
});
|
||||
items.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
|
||||
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 permissions = profile?.systemPermissions;
|
||||
|
||||
return filteredItems;
|
||||
}, [mockRoleOverride, profile]);
|
||||
return items.filter((item) => {
|
||||
// Super Admin ALWAYS bypasses and gets full access to everything
|
||||
if (isSuperAdmin) {
|
||||
if (item.to === "/worksmobile") return showWorksmobile;
|
||||
return true;
|
||||
}
|
||||
|
||||
// For others, check their fine-grained systemPermissions
|
||||
if (!permissions) return false;
|
||||
|
||||
if (item.to === "/") return permissions.overview;
|
||||
if (item.to === "/users") return permissions.users;
|
||||
if (item.to === "/auth") return permissions.auth_guard;
|
||||
if (item.to === "/api-keys") return permissions.api_keys;
|
||||
if (item.to === "/audit-logs") return permissions.audit_logs;
|
||||
if (item.to === "/permissions-direct") return false;
|
||||
if (item.to === "/tenants") return permissions.tenants;
|
||||
if (item.to === orgfrontUrl) return permissions.org_chart;
|
||||
if (item.to === "/worksmobile") return permissions.worksmobile;
|
||||
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
|
||||
if (item.to === "/system/data-integrity")
|
||||
return permissions.data_integrity;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
@@ -185,7 +294,7 @@ function AppLayout() {
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
console.debug("[AppLayout] Auth state check:", {
|
||||
debugLog("[AppLayout] Auth state check:", {
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isTest,
|
||||
@@ -204,16 +313,28 @@ function AppLayout() {
|
||||
}, [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);
|
||||
applyShellTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
// Re-render when locale changes
|
||||
};
|
||||
|
||||
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
@@ -279,6 +400,10 @@ function AppLayout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
@@ -378,71 +503,110 @@ function AppLayout() {
|
||||
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 profileSummary = buildShellProfileSummary({
|
||||
profileName:
|
||||
profile?.name ||
|
||||
auth.user?.profile.name?.toString() ||
|
||||
auth.user?.profile.preferred_username?.toString(),
|
||||
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
|
||||
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const profileRoleKey = profile?.role || "user";
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||
writeShellSessionExpiryEnabled(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item) => {
|
||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||
const label = t(labelKey, labelFallback);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{label}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={item.end ?? to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
item.isActive !== undefined
|
||||
? item.isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle
|
||||
: isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
@@ -453,87 +617,29 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
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);
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
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">
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
<header className={shellLayoutClasses.headerElevated}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
@@ -542,12 +648,12 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<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"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -556,14 +662,10 @@ function AppLayout() {
|
||||
: 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>
|
||||
<SessionStatusBadge
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
@@ -572,17 +674,17 @@ function AppLayout() {
|
||||
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", "계정 메뉴 열기")}
|
||||
aria-label={t("ui.shell.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 className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -592,45 +694,50 @@ function AppLayout() {
|
||||
</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"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
{t("ui.shell.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</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}`,
|
||||
`ui.shell.role.${profileRoleKey}`,
|
||||
profileRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
{t(
|
||||
"ui.shell.session.auto_extend",
|
||||
"세션 만료 관리",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)}
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusText
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t(
|
||||
"ui.shell.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -707,7 +814,7 @@ function AppLayout() {
|
||||
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>
|
||||
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -718,7 +825,7 @@ function AppLayout() {
|
||||
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>
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -726,10 +833,9 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet context={isSidebarCollapsed} />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
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(() => {
|
||||
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||
const savedEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
setIsOverrideEnabled(savedEnabled);
|
||||
if (savedRole) {
|
||||
setCurrentRole(savedRole);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
const nextState = !isCollapsed;
|
||||
setIsCollapsed(nextState);
|
||||
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
|
||||
};
|
||||
|
||||
const switchRole = (role: string) => {
|
||||
window.localStorage.setItem("X-Mock-Role", role);
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
setCurrentRole(role);
|
||||
setIsOverrideEnabled(true);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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: "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={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleSwitcher;
|
||||
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders image and fallback with merged classes", async () => {
|
||||
const root = await render(
|
||||
<Avatar className="custom-root" data-testid="avatar">
|
||||
<AvatarImage
|
||||
alt="Admin user"
|
||||
className="custom-image"
|
||||
src="/avatar.png"
|
||||
/>
|
||||
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
|
||||
const avatar = container?.querySelector("[data-testid='avatar']");
|
||||
const fallback = container?.textContent;
|
||||
|
||||
expect(avatar?.className).toContain("custom-root");
|
||||
expect(fallback).toContain("AU");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef<
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "text-foreground",
|
||||
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonButtonSize,
|
||||
type CommonButtonVariant,
|
||||
getCommonButtonClasses,
|
||||
} from "../../../../common/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-6 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -1,72 +1,58 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
commonCardClass,
|
||||
commonCardContentClass,
|
||||
commonCardDescriptionClass,
|
||||
commonCardFooterClass,
|
||||
commonCardHeaderClass,
|
||||
commonCardTitleClass,
|
||||
} from "../../../../common/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
|
||||
19
adminfront/src/components/ui/checkbox.test.tsx
Normal file
19
adminfront/src/components/ui/checkbox.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
describe("Checkbox Component", () => {
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Checkbox aria-label="Select row" />);
|
||||
|
||||
expect(screen.getByRole("checkbox")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Checkbox id="explicit-checkbox" name="explicit-name" />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).toHaveAttribute("id", "explicit-checkbox");
|
||||
expect(checkbox).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,18 @@ export interface CheckboxProps
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
({ className, onCheckedChange, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheckedChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
id={fieldId}
|
||||
name={name}
|
||||
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",
|
||||
|
||||
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
23
adminfront/src/components/ui/dialog.focus-scope.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog FocusScope integration", () => {
|
||||
it("mounts an open dialog without a ref update loop", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Focus scope check</DialogTitle>
|
||||
<DialogDescription>Dialog content is mounted.</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,222 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
type DialogContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
function useDialogContext(componentName: string) {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error(`${componentName} must be used within Dialog`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
function composeEventHandlers<E extends React.SyntheticEvent>(
|
||||
theirs: ((event: E) => void) | undefined,
|
||||
ours: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type DialogProps = {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Dialog({
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const currentOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ open: currentOpen, setOpen }),
|
||||
[currentOpen, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogTrigger");
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(true),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleOpen} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTrigger.displayName = "DialogTrigger";
|
||||
|
||||
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ asChild = false, children, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogClose");
|
||||
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...props,
|
||||
onClick: composeEventHandlers(
|
||||
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
|
||||
() => setOpen(false),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" ref={ref} onClick={handleClose} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogClose.displayName = "DialogClose";
|
||||
|
||||
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
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, onMouseDown, ...props }, ref) => {
|
||||
const { setOpen } = useDialogContext("DialogOverlay");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
"fixed inset-0 z-50 border-0 bg-black/80 p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-state="open"
|
||||
aria-label="Close dialog"
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setOpen(false);
|
||||
}
|
||||
})}
|
||||
{...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;
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDialogElement,
|
||||
React.HTMLAttributes<HTMLDialogElement>
|
||||
>(({ className, children, onKeyDown, ...props }, ref) => {
|
||||
const { open, setOpen } = useDialogContext("DialogContent");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
return () => document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<dialog
|
||||
ref={ref}
|
||||
open
|
||||
aria-modal="true"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 m-0 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-transparent 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,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose 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>
|
||||
</DialogClose>
|
||||
</dialog>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -80,10 +247,10 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
@@ -92,29 +259,29 @@ const DialogTitle = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -183,18 +183,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,20 @@ describe("Input Component", () => {
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Enter text")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Input id="explicit-id" name="explicit-name" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
expect(input).toHaveAttribute("id", "explicit-id");
|
||||
expect(input).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
|
||||
it("handles value changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { commonInputClass } from "../../../../common/ui/input";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
({ className, type, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
return (
|
||||
<input
|
||||
id={fieldId}
|
||||
name={name}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
41
adminfront/src/components/ui/separator.test.tsx
Normal file
41
adminfront/src/components/ui/separator.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders a horizontal separator with custom classes", async () => {
|
||||
const root = await render(
|
||||
<Separator className="custom-separator" data-testid="separator" />,
|
||||
);
|
||||
|
||||
const separator = container?.querySelector("[data-testid='separator']");
|
||||
|
||||
expect(separator?.className).toContain("h-px");
|
||||
expect(separator?.className).toContain("custom-separator");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,68 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
interface SwitchProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const currentChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
const nextChecked = !currentChecked;
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={currentChecked}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableBodyClass,
|
||||
commonTableCaptionClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableFooterClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(commonTableHeaderClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||
className={cn(commonTableFooterClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -51,14 +58,7 @@ const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
@@ -66,14 +66,7 @@ const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-6 align-middle text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn(commonTableCaptionClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -103,11 +92,11 @@ TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
@@ -84,4 +84,4 @@ const TabsContent = React.forwardRef<
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
19
adminfront/src/components/ui/textarea.test.tsx
Normal file
19
adminfront/src/components/ui/textarea.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea Component", () => {
|
||||
it("adds a fallback id for browser autofill diagnostics", () => {
|
||||
render(<Textarea aria-label="Description" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("keeps explicit id and name values", () => {
|
||||
render(<Textarea id="explicit-textarea" name="explicit-name" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
expect(textarea).toHaveAttribute("id", "explicit-textarea");
|
||||
expect(textarea).toHaveAttribute("name", "explicit-name");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,14 @@ export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
({ className, id, name, ...props }, ref) => {
|
||||
const fallbackId = React.useId();
|
||||
const fieldId = id ?? (name ? undefined : fallbackId);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
name={name}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -18,6 +18,11 @@ const notify = () => {
|
||||
};
|
||||
|
||||
const toastBase = (message: string, type: ToastType = "success") => {
|
||||
if (
|
||||
toasts.some((toast) => toast.message === message && toast.type === type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
69
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
69
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiKey } from "../../lib/adminApi";
|
||||
import ApiKeyCreatePage from "./ApiKeyCreatePage";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
createApiKey: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id",
|
||||
scopes: ["audit:read", "user:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyCreatePage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyCreatePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders org-context:read as a selectable API key scope", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes org-context:read in the create request when selected", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("서비스 또는 목적 식별 이름"), {
|
||||
target: { value: "org-context-client" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createApiKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "org-context-client",
|
||||
scopes: expect.arrayContaining(["org-context:read"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,51 +28,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const AVAILABLE_SCOPES = [
|
||||
{
|
||||
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: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
];
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -298,7 +254,7 @@ function ApiKeyCreatePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
|
||||
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
125
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import ApiKeyListPage from "./ApiKeyListPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchApiKeys: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
deleteApiKey: vi.fn(async () => undefined),
|
||||
updateApiKeyScopes: vi.fn(async () => ({
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read", "org-context:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
})),
|
||||
rotateApiKeySecret: vi.fn(async () => ({
|
||||
apiKey: {
|
||||
id: "api-key-id",
|
||||
name: "org-context-client",
|
||||
client_id: "client-id-stable",
|
||||
scopes: ["audit:read"],
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
},
|
||||
clientSecret: "rotated-secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ApiKeyListPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeyListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("updates scopes without changing client_id", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
|
||||
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
|
||||
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
|
||||
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("rotates only the secret and shows the one-time secret", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
|
||||
});
|
||||
expect(
|
||||
await screen.findByDisplayValue("rotated-secret"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh button refetches the list without navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("client-id-stable");
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
|
||||
expect(refreshButton).toHaveAttribute("type", "button");
|
||||
|
||||
await user.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Edit3,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +23,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +40,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
import {
|
||||
type ApiKeySummary,
|
||||
deleteApiKey,
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
|
||||
null,
|
||||
);
|
||||
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
|
||||
const [rotatedSecret, setRotatedSecret] = React.useState<{
|
||||
key: ApiKeySummary;
|
||||
clientSecret: string;
|
||||
} | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchApiKeys(50, 0),
|
||||
@@ -35,6 +73,27 @@ function ApiKeyListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateScopesMutation = useMutation({
|
||||
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
|
||||
updateApiKeyScopes(id, { scopes }),
|
||||
onSuccess: () => {
|
||||
setEditingKey(null);
|
||||
setDraftScopes([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const rotateSecretMutation = useMutation({
|
||||
mutationFn: (id: string) => rotateApiKeySecret(id),
|
||||
onSuccess: (data) => {
|
||||
setRotatedSecret({
|
||||
key: data.apiKey,
|
||||
clientSecret: data.clientSecret,
|
||||
});
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -62,42 +121,80 @@ function ApiKeyListPage() {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const openScopeEditor = (key: ApiKeySummary) => {
|
||||
setEditingKey(key);
|
||||
setDraftScopes(key.scopes);
|
||||
};
|
||||
|
||||
const toggleDraftScope = (scopeId: string) => {
|
||||
setDraftScopes((current) =>
|
||||
current.includes(scopeId)
|
||||
? current.filter((scope) => scope !== scopeId)
|
||||
: [...current, scopeId],
|
||||
);
|
||||
};
|
||||
|
||||
const saveScopes = () => {
|
||||
if (!editingKey || draftScopes.length === 0) return;
|
||||
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
|
||||
};
|
||||
|
||||
const handleRotateSecret = (key: ApiKeySummary) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.api_keys.list.rotate_confirm",
|
||||
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
|
||||
{ name: key.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rotateSecretMutation.mutate(key.id);
|
||||
};
|
||||
|
||||
const copyRotatedSecret = () => {
|
||||
if (!rotatedSecret) return;
|
||||
navigator.clipboard.writeText(rotatedSecret.clientSecret);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
sticky
|
||||
titleAs="h2"
|
||||
icon={<Key size={20} />}
|
||||
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
description={t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -119,7 +216,7 @@ function ApiKeyListPage() {
|
||||
<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">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
@@ -189,15 +286,40 @@ function ApiKeyListPage() {
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openScopeEditor(key)}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.edit_scopes",
|
||||
"권한 수정",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRotateSecret(key)}
|
||||
disabled={rotateSecretMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret",
|
||||
"Secret 재발급",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -207,6 +329,137 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editingKey !== null}
|
||||
onOpenChange={() => setEditingKey(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKey
|
||||
? t(
|
||||
"msg.admin.api_keys.list.edit_scopes_desc",
|
||||
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
|
||||
{ clientId: editingKey.client_id },
|
||||
)
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = draftScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
onClick={() => toggleDraftScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">
|
||||
{t(scope.labelKey, scope.labelFallback)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(scope.descKey, scope.descFallback)}
|
||||
</span>
|
||||
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
|
||||
ID: {scope.id}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draftScopes.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scope_required",
|
||||
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingKey(null)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveScopes}
|
||||
disabled={
|
||||
updateScopesMutation.isPending || draftScopes.length === 0
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={rotatedSecret !== null}
|
||||
onOpenChange={() => setRotatedSecret(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret_done",
|
||||
"Secret 재발급 완료",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.rotate_secret_notice",
|
||||
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{rotatedSecret && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
CLIENT ID
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{rotatedSecret.key.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
X-Baron-Key-Secret
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
value={rotatedSecret.clientSecret}
|
||||
className="font-mono pr-12"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={copyRotatedSecret}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRotatedSecret(null)}>
|
||||
{t("ui.common.confirm", "확인")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ApiKeyScopeOption = {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
descKey: string;
|
||||
descFallback: string;
|
||||
};
|
||||
|
||||
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
|
||||
{
|
||||
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: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
{
|
||||
id: "org-context:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
|
||||
labelFallback: "조직 Context 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
|
||||
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
|
||||
},
|
||||
];
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -19,92 +13,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const defaultAuditFilters = [
|
||||
"method:POST path:/api/v1/*",
|
||||
"status:failure",
|
||||
"latency_ms:>1000",
|
||||
];
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
tenant_id?: string;
|
||||
actor_id?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
const date = parsed.toISOString().slice(0, 10);
|
||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
return { date, time };
|
||||
}
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -115,8 +35,23 @@ function AuditLogsPage() {
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
queryKey: [
|
||||
"audit-logs",
|
||||
deferredSearchActorId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const search = [deferredSearchActorId, deferredSearchAction]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return fetchAuditLogs(
|
||||
50,
|
||||
pageParam,
|
||||
search || undefined,
|
||||
statusFilter === "all" ? undefined : statusFilter,
|
||||
);
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
@@ -127,477 +62,133 @@ function AuditLogsPage() {
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const trimmed = filterDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||||
setFilterDraft("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.audit.title", "감사 로그")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="glass-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>
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("ui.common.audit.title", "감사 로그")}
|
||||
description={t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"관리자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
icon={<NotebookTabs size={20} />}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<Download size={16} />
|
||||
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.audit.registry.description",
|
||||
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center" data-testid="audit-loading">
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="p-8 text-center text-red-500"
|
||||
data-testid="audit-error"
|
||||
>
|
||||
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.filters.remove",
|
||||
"{{filter}} 필터 제거",
|
||||
{ filter },
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">
|
||||
{t("ui.admin.audit.table.time", "TIME")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.path", "PATH")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.admin.audit.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.audit.table.action_target",
|
||||
"Action / Target",
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
data-testid="audit-search-user-id"
|
||||
value={searchActorId}
|
||||
onChange={(event) => setSearchActorId(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.user_id",
|
||||
"Filter by User ID",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t(
|
||||
"msg.admin.audit.empty",
|
||||
"아직 수집된 감사 로그가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.actor_id",
|
||||
"Copy actor id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.request_id",
|
||||
"Copy request id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.target",
|
||||
"Target · {{target}}",
|
||||
{
|
||||
target: details.target,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.target",
|
||||
"Copy target",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.target || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.request",
|
||||
"Request",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.admin.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.actor_id",
|
||||
"Actor ID · {{value}}",
|
||||
{
|
||||
value:
|
||||
row.user_id ||
|
||||
details.actor_id ||
|
||||
"-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.tenant_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.result",
|
||||
"Result",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-center flex-shrink-0">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-testid="audit-search-action"
|
||||
value={searchAction}
|
||||
onChange={(event) =>
|
||||
setSearchAction(event.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
id="audit-filter-status"
|
||||
name="audit-filter-status"
|
||||
data-testid="audit-filter-status"
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<VirtualizedAuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../../../common/core/audit";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../../common/ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type VirtualizedAuditLogTableProps = {
|
||||
logs: AuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
}
|
||||
|
||||
export function VirtualizedAuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: VirtualizedAuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" &&
|
||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 80,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
overscan: isTest ? logs.length : 10,
|
||||
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) {
|
||||
return;
|
||||
}
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= logs.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
logs.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isTest,
|
||||
]);
|
||||
|
||||
const tableMinWidth = 1010;
|
||||
|
||||
const renderRow = (
|
||||
row: AuditLog,
|
||||
index: number,
|
||||
virtualRow?: { start: number; end: number },
|
||||
) => {
|
||||
if (!row) return null;
|
||||
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
data-index={index}
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cx(
|
||||
commonTableRowClass,
|
||||
"bg-card/40",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className={cx("flex items-center", expanded && "border-b")}>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[190px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[180px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold text-foreground">{actionLabel}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[260px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[80px] shrink-0 text-right",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() => {
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}));
|
||||
// Re-measure after state change
|
||||
setTimeout(() => rowVirtualizer.measure(), 0);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(row.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(row.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.method", "Method · {{value}}", {
|
||||
value: formatAuditValue(details.method),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.path", "Path · {{value}}", {
|
||||
value: formatAuditValue(details.path),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
|
||||
value: formatAuditValue(details.tenant_id),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.device", "Device · {{value}}", {
|
||||
value: formatAuditValue(row.device_id),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.error", "Error · {{value}}", {
|
||||
value: formatAuditValue(details.error),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.before", "Before · {{value}}", {
|
||||
value: formatAuditValue(details.before),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.after", "After · {{value}}", {
|
||||
value: formatAuditValue(details.after),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cx(commonTableViewportClass, "flex-1")}
|
||||
data-testid="audit-table-viewport"
|
||||
>
|
||||
<div
|
||||
className={commonTableWrapperClass}
|
||||
style={{ minWidth: tableMinWidth }}
|
||||
>
|
||||
<table
|
||||
className={cx(commonTableClass, "table-fixed w-full")}
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead
|
||||
className={cx(
|
||||
commonTableHeaderClass,
|
||||
commonStickyTableHeaderClass,
|
||||
)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={commonTableBodyClass}
|
||||
style={
|
||||
!isTest
|
||||
? {
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isTest
|
||||
? logs.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
logs[virtualRow.index],
|
||||
virtualRow.index,
|
||||
virtualRow,
|
||||
),
|
||||
)}
|
||||
{logs.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center py-8 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ui.common.audit.table.no_logs", "No audit logs found")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
|
||||
{hasNextPage ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="animate-pulse text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "더 보기")}
|
||||
</Button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { ShieldHalf } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[AuthCallbackPage] State:", {
|
||||
debugLog("[AuthCallbackPage] State:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
error: auth.error,
|
||||
|
||||
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
|
||||
const authState = {
|
||||
activeNavigator: undefined,
|
||||
error: undefined as Error | undefined,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
removeUser: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
function renderAuthGuard(initialEntry = "/users") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthGuard />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthGuard", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = undefined;
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.removeUser.mockClear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
|
||||
window.localStorage.setItem("admin_session", "stale-token");
|
||||
authState.error = new Error("stale session");
|
||||
|
||||
renderAuthGuard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
});
|
||||
await screen.findByText("Login outlet");
|
||||
expect(window.localStorage.getItem("admin_session")).toBeNull();
|
||||
});
|
||||
});
|
||||
59
adminfront/src/features/auth/AuthGuard.tsx
Normal file
59
adminfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { clearStoredAdminAuthSession } from "../../lib/auth";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const handledAuthErrorRef = useRef(false);
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.error || handledAuthErrorRef.current || isTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledAuthErrorRef.current = true;
|
||||
clearStoredAdminAuthSession();
|
||||
void Promise.resolve(
|
||||
auth.removeUser ? auth.removeUser() : undefined,
|
||||
).finally(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}, [auth, auth.error, isTest, navigate]);
|
||||
|
||||
if (isTest) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-4 text-destructive">
|
||||
<h2 className="text-xl font-bold">인증 오류</h2>
|
||||
<p>{auth.error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
38
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
38
adminfront/src/features/auth/AuthPage.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuthPage from "./AuthPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
});
|
||||
|
||||
it("renders localized auth guard labels in English", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Check permission" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,109 +1,22 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "Admin login",
|
||||
description:
|
||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||
pill: "15m TTL",
|
||||
},
|
||||
{
|
||||
title: "Tenant pick",
|
||||
description:
|
||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||
pill: "Header-ready",
|
||||
},
|
||||
{
|
||||
title: "Device approval",
|
||||
description:
|
||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||
pill: "App session",
|
||||
},
|
||||
];
|
||||
import { ShieldHalf } from "lucide-react";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||
IDP session placeholder
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Connect auth layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
titleAs="h2"
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t("ui.admin.auth_guard.title", "Auth Guard")}
|
||||
description={t(
|
||||
"ui.admin.auth_guard.subtitle",
|
||||
"Verify admin privileges and ReBAC relationships against the policy engine.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.title}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||
<span>{flow.pill}</span>
|
||||
<Fingerprint size={14} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{flow.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Smartphone size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
App-based approvals
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
App session as MFA replacement
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
If the admin keeps the mobile app signed in and opts in, use
|
||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||
SMS/email based on user choice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ArrowRight size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
TTL discipline
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Keep admin sessions short
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||
with step-up MFA when critical actions (rotate secret, export logs)
|
||||
happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
76
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
76
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const mockSigninRedirect = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
function renderLoginPage(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
mockSigninRedirect.mockReset();
|
||||
mockUseAuth.mockReturnValue({
|
||||
activeNavigator: undefined,
|
||||
error: undefined,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
signinRedirect: mockSigninRedirect,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
|
||||
renderLoginPage("/login?returnTo=%2F");
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/SSO 로그인을 시작할 수 없습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the returnTo query when starting SSO manually", async () => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: { subtle: {} },
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
|
||||
);
|
||||
|
||||
expect(mockSigninRedirect).toHaveBeenCalledWith({
|
||||
state: {
|
||||
returnTo: "/users?page=2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -10,17 +10,39 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
const insecurePkceMessage =
|
||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||
|
||||
function isPkceSetupFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const returnTo = searchParams.get("returnTo") || "/";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
const authErrorMessage = useMemo(() => {
|
||||
const message = auth.error?.message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (message.includes("Crypto.subtle")) {
|
||||
return insecurePkceMessage;
|
||||
}
|
||||
return message;
|
||||
}, [auth.error?.message]);
|
||||
const visibleLoginError = loginError || authErrorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[LoginPage] Auth state check:", {
|
||||
debugLog("[LoginPage] Auth state check:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
returnTo,
|
||||
@@ -41,21 +63,46 @@ function LoginPage() {
|
||||
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||
return;
|
||||
}
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
void auth
|
||||
.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Auto login redirect failed", error);
|
||||
});
|
||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/",
|
||||
},
|
||||
});
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
setLoginError(null);
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
await auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Redirect login failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,8 +131,7 @@ function LoginPage() {
|
||||
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;
|
||||
void handleSSOLogin();
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
@@ -123,6 +169,16 @@ function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{visibleLoginError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{visibleLoginError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
<br />
|
||||
|
||||
188
adminfront/src/features/auth/components/PermissionChecker.tsx
Normal file
188
adminfront/src/features/auth/components/PermissionChecker.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, 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";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
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="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.description",
|
||||
"Check in real time whether a subject has access to a resource through 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>
|
||||
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
|
||||
</Label>
|
||||
<select
|
||||
id="permission-checker-namespace"
|
||||
name="permission-checker-namespace"
|
||||
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">
|
||||
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
|
||||
</option>
|
||||
<option value="TenantGroup">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.tenant_group",
|
||||
"TenantGroup",
|
||||
)}
|
||||
</option>
|
||||
<option value="RelyingParty">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.namespace.relying_party",
|
||||
"RelyingParty",
|
||||
)}
|
||||
</option>
|
||||
<option value="System">
|
||||
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.relation", "Relation")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.relation_placeholder",
|
||||
"view, manage, admins...",
|
||||
)}
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.object_id_placeholder",
|
||||
"Tenant UUID, etc.",
|
||||
)}
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.auth_guard.checker.subject_placeholder",
|
||||
"User:uuid or 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 px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending
|
||||
? t("ui.admin.auth_guard.checker.checking", "Checking...")
|
||||
: t("ui.admin.auth_guard.checker.check", "Check permission")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "border-green-500/50 bg-green-500/10 text-green-600"
|
||||
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.allowed_description",
|
||||
"The subject has access to the requested resource, including inherited permissions.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-lg font-bold">
|
||||
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
|
||||
</div>
|
||||
<p className="text-center text-sm opacity-80">
|
||||
{t(
|
||||
"ui.admin.auth_guard.checker.denied_description",
|
||||
"The subject does not have access to the requested resource.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionChecker;
|
||||
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../auth/AuthCallbackPage";
|
||||
import AuthGuard from "../auth/AuthGuard";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
state: undefined as unknown,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../../../common/core/components/audit", () => ({
|
||||
AuditLogTable: ({
|
||||
logs,
|
||||
}: {
|
||||
logs: Array<{ user_id: string; event_type: string }>;
|
||||
}) => (
|
||||
<div>
|
||||
{logs.map((log) => (
|
||||
<div key={`${log.user_id}-${log.event_type}`}>
|
||||
<span>{log.user_id}</span>
|
||||
<span>{log.event_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "admin-1",
|
||||
event_type: "USER_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
|
||||
},
|
||||
{
|
||||
event_id: "event-2",
|
||||
timestamp: "2026-05-01T01:00:00Z",
|
||||
user_id: "admin-2",
|
||||
event_type: "LOGIN_FAILED",
|
||||
status: "failure",
|
||||
ip_address: "127.0.0.2",
|
||||
user_agent: "Vitest",
|
||||
details: "{}",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin audit and auth coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = {
|
||||
access_token: "access-token",
|
||||
state: undefined,
|
||||
};
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders audit log table with fetched events", async () => {
|
||||
renderWithProviders(<AuditLogsPage />);
|
||||
|
||||
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
|
||||
expect(await screen.findByText("admin-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("인증 오류")).toBeInTheDocument();
|
||||
|
||||
authState.error = null;
|
||||
authState.isAuthenticated = false;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/secure?x=1",
|
||||
);
|
||||
expect(screen.getByText("Login outlet")).toBeInTheDocument();
|
||||
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stores callback token and navigates by auth result", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = {
|
||||
access_token: "callback-token",
|
||||
state: { returnTo: "/users" },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/users" element={<div>Users outlet</div>} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
506
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
506
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import * as adminApi from "../../lib/adminApi";
|
||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||
import UserCreatePage from "../users/UserCreatePage";
|
||||
import UserDetailPage from "../users/UserDetailPage";
|
||||
|
||||
const tenantItems = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "root",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "company",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "leaf",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const userDetail = {
|
||||
id: "user-1",
|
||||
email: "engineer@example.com",
|
||||
name: "Engineer User",
|
||||
phone: "010-0000-0000",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantId: "tenant-leaf",
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
sub_email: ["engineer.sub@example.com"],
|
||||
},
|
||||
tenant: tenantItems[2],
|
||||
appointments: [
|
||||
{
|
||||
tenantId: "tenant-leaf",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantName: "기술연구팀",
|
||||
isPrimary: true,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: true,
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: { employee_id: "EMP001" },
|
||||
},
|
||||
],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../components/auth/RoleGuard", () => ({
|
||||
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
total: tenantItems.length,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
total: tenantItems.length,
|
||||
nextCursor: null,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
|
||||
}),
|
||||
createUser: vi.fn(async () => ({
|
||||
id: "created-user",
|
||||
email: "created@example.com",
|
||||
generatedPassword: "GeneratedPassword!1",
|
||||
})),
|
||||
fetchUser: vi.fn(async () => userDetail),
|
||||
fetchUserRpHistory: vi.fn(async () => [
|
||||
{
|
||||
client_id: "orgfront",
|
||||
client_name: "OrgFront",
|
||||
last_login_at: "2026-05-01T00:00:00Z",
|
||||
login_count: 3,
|
||||
},
|
||||
]),
|
||||
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
minCharacterTypes: 3,
|
||||
})),
|
||||
updateUser: vi.fn(async () => userDetail),
|
||||
deleteUser: vi.fn(async () => undefined),
|
||||
updateTenant: vi.fn(async () => tenantItems[1]),
|
||||
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
|
||||
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
|
||||
importTenantsCSV: vi.fn(async () => ({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
})),
|
||||
fetchWorksmobileOverview: vi.fn(async () => ({
|
||||
tenant: tenantItems[1],
|
||||
config: {
|
||||
enabled: true,
|
||||
tokenConfigured: true,
|
||||
adminTenantId: "works-admin",
|
||||
domainMappings: { "example.com": 1001 },
|
||||
},
|
||||
recentJobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
resourceType: "USER",
|
||||
resourceId: "user-1",
|
||||
action: "SYNC",
|
||||
status: "failed",
|
||||
retryCount: 1,
|
||||
lastError: "temporary failure",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:10:00Z",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileComparison: vi.fn(async () => ({
|
||||
users: [
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-1",
|
||||
baronName: "Engineer User",
|
||||
baronEmail: "engineer@example.com",
|
||||
baronPrimaryOrgId: "tenant-leaf",
|
||||
baronPrimaryOrgName: "기술연구팀",
|
||||
worksmobileId: "works-user-1",
|
||||
worksmobileName: "Engineer User",
|
||||
worksmobileEmail: "engineer@example.com",
|
||||
worksmobileDomainId: 1001,
|
||||
worksmobilePrimaryOrgId: "works-org-1",
|
||||
worksmobilePrimaryOrgName: "기술연구팀",
|
||||
status: "matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-2",
|
||||
baronName: "New User",
|
||||
baronEmail: "new@example.com",
|
||||
worksmobileJobStatus: "failed",
|
||||
worksmobileJobRetryCount: 2,
|
||||
worksmobileLastError: "worksmobile api failed",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-3",
|
||||
baronName: "Next User",
|
||||
baronEmail: "next@example.com",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
resourceType: "ORG_UNIT",
|
||||
baronId: "tenant-leaf",
|
||||
baronSlug: "gpdtdc-rnd",
|
||||
baronName: "기술연구팀",
|
||||
worksmobileId: "works-org-1",
|
||||
worksmobileName: "기술연구팀",
|
||||
status: "needs_update",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileCredentialBatches: vi.fn(async () => [
|
||||
{
|
||||
batchId: "credential-batch-1",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 1,
|
||||
processedCount: 1,
|
||||
failedCount: 1,
|
||||
hasPasswords: true,
|
||||
failures: [
|
||||
{
|
||||
userId: "failed-user",
|
||||
email: "failed-user@samaneng.com",
|
||||
status: "failed",
|
||||
retryCount: 2,
|
||||
lastError: "worksmobile api failed",
|
||||
updatedAt: "2026-06-01T04:05:00Z",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-01T04:00:00Z",
|
||||
updatedAt: "2026-06-01T04:00:00Z",
|
||||
},
|
||||
{
|
||||
batchId: "credential-batch-pending",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 2,
|
||||
pendingCount: 1,
|
||||
processingCount: 1,
|
||||
processedCount: 0,
|
||||
failedCount: 0,
|
||||
hasPasswords: true,
|
||||
createdAt: "2026-06-01T04:10:00Z",
|
||||
updatedAt: "2026-06-01T04:10:00Z",
|
||||
},
|
||||
]),
|
||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["id"]),
|
||||
filename: "worksmobile_initial_passwords.csv",
|
||||
})),
|
||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
|
||||
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
|
||||
batchId: "credential-batch-1",
|
||||
userCount: 1,
|
||||
hasPasswords: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("adminfront large page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any)._IS_TEST_MODE = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders user creation form with tenant context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/new" element={<UserCreatePage />} />
|
||||
</Routes>,
|
||||
"/users/new?tenantSlug=gpdtdc-rnd",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders user detail form and RP history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>,
|
||||
"/users/user-1",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant list hierarchy", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants" element={<TenantListPage />} />
|
||||
</Routes>,
|
||||
"/tenants",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders worksmobile comparison screens", async () => {
|
||||
cleanup();
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Baron / Works 비교")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("최근 실패: worksmobile api failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
|
||||
vi.mocked(adminApi.enqueueWorksmobileUserSync)
|
||||
.mockRejectedValueOnce(new Error("sync failed"))
|
||||
.mockResolvedValueOnce({ id: "job-user-3" } as never);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders and retries Worksmobile jobs from history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
||||
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
|
||||
await waitFor(() =>
|
||||
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"job-1",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens Worksmobile password management for matched users", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("Worksmobile 연동");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 관리",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"https://auth.worksmobile.com/integrate/password/manage",
|
||||
),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
const [url] = openSpy.mock.calls[0] ?? [];
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
|
||||
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
|
||||
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function listSourceFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(directory, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listSourceFiles(path));
|
||||
continue;
|
||||
}
|
||||
if (path.endsWith(".tsx")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("admin page animation policy", () => {
|
||||
it("does not use long enter fade animations on stable page containers", () => {
|
||||
const sourceRoot = join(process.cwd(), "src");
|
||||
const offenders = listSourceFiles(sourceRoot).filter((file) =>
|
||||
readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
|
||||
);
|
||||
|
||||
expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
|
||||
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
domains: ["hmac.kr"],
|
||||
config: { visibility: "public" },
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "실 조직",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
domains: ["gpdtdc.example.com"],
|
||||
config: {
|
||||
visibility: "public",
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
isLoginId: true,
|
||||
indexed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
|
||||
}),
|
||||
createTenant: vi.fn(async () => tenants[1]),
|
||||
updateTenant: vi.fn(async () => tenants[1]),
|
||||
deleteTenant: vi.fn(async () => undefined),
|
||||
approveTenant: vi.fn(async () => tenants[1]),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant detail page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders tenant create page with parent context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/new" element={<TenantCreatePage />} />
|
||||
</Routes>,
|
||||
"/tenants/new?parentId=tenant-root",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
|
||||
expect(screen.getByText("정책 메모")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant profile and schema management pages", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId"
|
||||
element={
|
||||
<>
|
||||
<TenantProfilePage />
|
||||
<TenantSchemaPage />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
|
||||
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
117
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
117
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
|
||||
|
||||
const tenant = {
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const members = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
|
||||
fetchTenant: vi.fn(async () => tenant),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Candidate User",
|
||||
email: "candidate@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})),
|
||||
fetchGroups: vi.fn(async () => [
|
||||
{
|
||||
id: "group-root",
|
||||
tenantId: "tenant-company",
|
||||
name: "연구소",
|
||||
description: "root group",
|
||||
members,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "group-child",
|
||||
tenantId: "tenant-company",
|
||||
parentId: "group-root",
|
||||
name: "플랫폼팀",
|
||||
description: "child group",
|
||||
members: [],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createGroup: vi.fn(async () => undefined),
|
||||
deleteGroup: vi.fn(async () => undefined),
|
||||
addGroupMember: vi.fn(async () => undefined),
|
||||
removeGroupMember: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantGroupsPage coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders group hierarchy and selected group members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/groups"
|
||||
element={<TenantGroupsPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/groups",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
276
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
276
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
blob: new Blob(["email,name\nmember@example.com,Member User\n"], {
|
||||
type: "text/csv",
|
||||
}),
|
||||
filename: "users_export_20260609.csv",
|
||||
})),
|
||||
);
|
||||
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn(async () => ({ results: [] })));
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: "user-owner",
|
||||
name: "Owner User",
|
||||
email: "owner@example.com",
|
||||
role: "super_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "tenant_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-member",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenant: tenants[2],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => users[0]),
|
||||
fetchTenant: vi.fn(async (tenantId) => ({
|
||||
id: tenantId,
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
userPermissions: { view: true, manage: true, manage_admins: true },
|
||||
})),
|
||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: vi.fn(async () => undefined),
|
||||
removeTenantAdmin: vi.fn(async () => undefined),
|
||||
fetchTenantRelations: vi.fn(async () => [
|
||||
{
|
||||
userId: "user-relation-1",
|
||||
name: "Relation User",
|
||||
email: "relation@example.com",
|
||||
relations: ["profile_managers", "schema_viewers"],
|
||||
},
|
||||
]),
|
||||
addTenantRelation: vi.fn(async () => undefined),
|
||||
removeTenantRelation: vi.fn(async () => undefined),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
updateTenant: vi.fn(async () => tenants[2]),
|
||||
updateUser: vi.fn(async () => users[2]),
|
||||
bulkUpdateUsers: bulkUpdateUsersMock,
|
||||
exportTenantsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["name,slug"]),
|
||||
filename: "tenants.csv",
|
||||
})),
|
||||
exportUsersCSV: exportUsersCSVMock,
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant tab coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
|
||||
"blob:tenant-users-export",
|
||||
);
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("renders tenant owners and admins lists", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/permissions"
|
||||
element={<TenantAdminsAndOwnersTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/permissions",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Owner User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin User")).toBeInTheDocument();
|
||||
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant fine-grained relations list", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/relations"
|
||||
element={<TenantFineGrainedPermissionsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/relations",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Relation User")).toBeInTheDocument();
|
||||
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant hierarchy and selected organization members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports selected organization users by tenant slug", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
|
||||
});
|
||||
});
|
||||
|
||||
it("queues searched users and bulk adds them to the selected organization", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /멤버 추가/ }));
|
||||
fireEvent.change(screen.getByTestId("tenant-org-member-search-input"), {
|
||||
target: { value: "user" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("tenant-org-member-search-btn"));
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByTestId("tenant-org-member-search-result-user-owner"),
|
||||
);
|
||||
fireEvent.click(
|
||||
await screen.findByTestId("tenant-org-member-search-result-user-admin"),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
|
||||
"Owner User",
|
||||
);
|
||||
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
|
||||
"Admin User",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-org-member-add-submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||
userIds: ["user-owner", "user-admin"],
|
||||
tenantSlug: "gpdtdc",
|
||||
isAddTenant: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
197
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
197
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrphanUserLoginIDs,
|
||||
} from "../../lib/adminApi";
|
||||
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import DataIntegrityPage from "./DataIntegrityPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
const integrityReport = {
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 2,
|
||||
passed: 1,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
key: "duplicate_tenant_slugs",
|
||||
label: "중복 테넌트 slug",
|
||||
description: "active tenant slug의 대소문자 무시 중복을 검사합니다.",
|
||||
status: "fail",
|
||||
severity: "error",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchDataIntegrityReport: vi.fn(async () => integrityReport),
|
||||
fetchOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "login-id-1",
|
||||
userId: "user-1",
|
||||
userEmail: "missing@example.com",
|
||||
tenantId: "tenant-1",
|
||||
tenantSlug: "deleted-tenant",
|
||||
fieldKey: "emp_id",
|
||||
loginId: "EMP001",
|
||||
reasons: ["deleted_tenant"],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
deleteOrphanUserLoginIDs: vi.fn(async () => ({
|
||||
deletedCount: 1,
|
||||
deleted: [
|
||||
{
|
||||
id: "login-id-1",
|
||||
userId: "user-1",
|
||||
tenantId: "tenant-1",
|
||||
fieldKey: "emp_id",
|
||||
loginId: "EMP001",
|
||||
reasons: ["deleted_tenant"],
|
||||
},
|
||||
],
|
||||
skippedIds: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataIntegrityPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataIntegrityPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders integrity report for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows orphan login ID targets and deletes selected rows", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const { container } = renderPage();
|
||||
|
||||
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
|
||||
expect(await screen.findByText("EMP001")).toBeInTheDocument();
|
||||
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
|
||||
expectNoAnonymousFormFields(container);
|
||||
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
|
||||
});
|
||||
expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
|
||||
"login-id-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables recheck button and shows manual recheck progress", async () => {
|
||||
let finishRecheck: (value: typeof integrityReport) => void = () => {};
|
||||
const pendingRecheck = new Promise<typeof integrityReport>((resolve) => {
|
||||
finishRecheck = resolve;
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||
vi.mocked(fetchDataIntegrityReport).mockImplementationOnce(
|
||||
() => pendingRecheck,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 검사" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("정합성 검사를 실행 중입니다."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
finishRecheck(integrityReport);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(fetchMe).toHaveBeenCalled();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders localized integrity labels in English", async () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Review integrity status and inspect checks across the admin data model.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Duplicate tenant slug"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
595
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
595
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
type DataIntegrityCheck,
|
||||
type DataIntegrityStatus,
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchOrphanUserLoginIDs,
|
||||
type OrphanUserLoginID,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
function statusLabel(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
case "fail":
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "success";
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
|
||||
if (check.status === "pass") {
|
||||
return <CheckCircle2 className="text-emerald-600" size={18} />;
|
||||
}
|
||||
if (check.status === "warning") {
|
||||
return <AlertTriangle className="text-amber-600" size={18} />;
|
||||
}
|
||||
return <ShieldAlert className="text-destructive" size={18} />;
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string) {
|
||||
switch (reason) {
|
||||
case "missing_user":
|
||||
return t("ui.admin.integrity.reason.missing_user", "사용자 없음");
|
||||
case "deleted_user":
|
||||
return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자");
|
||||
case "missing_tenant":
|
||||
return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음");
|
||||
case "deleted_tenant":
|
||||
return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트");
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function integritySectionDescription(key: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t(
|
||||
"msg.admin.integrity.section.tenant_integrity.description",
|
||||
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
|
||||
);
|
||||
case "user_integrity":
|
||||
return t(
|
||||
"msg.admin.integrity.section.user_integrity.description",
|
||||
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function integrityCheckLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "duplicate_tenant_slugs":
|
||||
return t(
|
||||
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_tenant_parents":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_tenant_parents.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_tenant_memberships":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_tenants":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_users":
|
||||
return t(
|
||||
"ui.admin.integrity.check.orphan_user_login_id_users.title",
|
||||
fallback,
|
||||
);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function integrityCheckDescription(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "duplicate_tenant_slugs":
|
||||
return t(
|
||||
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_tenant_parents":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_tenant_parents.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_tenant_memberships":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_tenants":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
|
||||
fallback,
|
||||
);
|
||||
case "orphan_user_login_id_users":
|
||||
return t(
|
||||
"msg.admin.integrity.check.orphan_user_login_id_users.description",
|
||||
fallback,
|
||||
);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t(
|
||||
"msg.admin.integrity.recheck.running",
|
||||
"정합성 검사를 실행 중입니다.",
|
||||
);
|
||||
case "success":
|
||||
return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다.");
|
||||
case "error":
|
||||
return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다.");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function OrphanLoginIDTable({
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
}: {
|
||||
items: OrphanUserLoginID[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.empty",
|
||||
"삭제할 유령 로그인 ID가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selectedIds);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded border border-border/60">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2">
|
||||
{t("ui.admin.integrity.table.select", "선택")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.login_id", "Login ID")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.field", "Field")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.user", "User")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.tenant", "Tenant")}
|
||||
</th>
|
||||
<th className="px-3 py-2">
|
||||
{t("ui.admin.integrity.table.reason", "사유")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
name={`orphan-login-id-select-${item.id}`}
|
||||
type="checkbox"
|
||||
aria-label={t(
|
||||
"ui.admin.integrity.table.select_item",
|
||||
"{{loginId}} 선택",
|
||||
{ loginId: item.loginId },
|
||||
)}
|
||||
checked={selectedSet.has(item.id)}
|
||||
onChange={() => onToggle(item.id)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{item.loginId}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{item.fieldKey}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>{item.userEmail || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.userId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>{item.tenantSlug || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.tenantId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.reasons.map((reason) => (
|
||||
<Badge key={reason} variant="warning">
|
||||
{reasonLabel(reason)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataIntegrityContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
|
||||
const [recheckStatus, setRecheckStatus] = useState<
|
||||
"idle" | "running" | "success" | "error"
|
||||
>("idle");
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ["data-integrity-report"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
});
|
||||
const orphanLoginIDsQuery = useQuery({
|
||||
queryKey: ["orphan-user-login-ids"],
|
||||
queryFn: fetchOrphanUserLoginIDs,
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrphanUserLoginIDs,
|
||||
onSuccess: async () => {
|
||||
setSelectedOrphanIds([]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
|
||||
const toggleOrphanID = (id: string) => {
|
||||
setSelectedOrphanIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((selectedID) => selectedID !== id)
|
||||
: [...current, id],
|
||||
);
|
||||
};
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedOrphanIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_confirm",
|
||||
"선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?",
|
||||
{ count: selectedOrphanIds.length },
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
deleteMutation.mutate(selectedOrphanIds);
|
||||
}
|
||||
};
|
||||
const isManualRechecking = recheckStatus === "running";
|
||||
const handleRecheck = async () => {
|
||||
if (isManualRechecking) {
|
||||
return;
|
||||
}
|
||||
setRecheckStatus("running");
|
||||
const result = await refetch();
|
||||
setRecheckStatus(result.isError ? "error" : "success");
|
||||
};
|
||||
const recheckMessage = recheckStatusText(recheckStatus);
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.subtitle",
|
||||
"Review integrity status and inspect checks across the admin data model.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRecheck}
|
||||
disabled={isLoading || isFetching || isManualRechecking}
|
||||
>
|
||||
<Database size={16} />
|
||||
{isManualRechecking
|
||||
? t("ui.admin.integrity.recheck.running", "검사 중")
|
||||
: t("ui.admin.integrity.recheck.run", "다시 검사")}
|
||||
</Button>
|
||||
{recheckMessage ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{recheckMessage}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 pb-6">
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.integrity.report.load_error",
|
||||
"정합성 리포트를 불러오지 못했습니다.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.read_model.title",
|
||||
"Read model integrity",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.read_model.description",
|
||||
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.loading", "불러오는 중")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.totalChecks ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.passed", "정상")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.passed ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.failures", "실패 건수")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.failures ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.checkedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(data?.sections ?? []).map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="rounded-lg border border-border bg-card p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{integritySectionLabel(section.key, section.label)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{integritySectionDescription(section.key)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(section.status)}>
|
||||
{statusLabel(section.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{section.checks.map((check) => (
|
||||
<div
|
||||
key={check.key}
|
||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon check={check} />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{integrityCheckLabel(check.key, check.label)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{integrityCheckDescription(
|
||||
check.key,
|
||||
check.description,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:justify-end">
|
||||
<Badge variant={statusBadgeVariant(check.status)}>
|
||||
{statusLabel(check.status)}
|
||||
</Badge>
|
||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||
{check.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.integrity.orphan_login_ids.title",
|
||||
"유령 로그인 ID 정리",
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.description",
|
||||
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={
|
||||
selectedOrphanIds.length === 0 || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
{orphanLoginIDsQuery.isError ? (
|
||||
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.load_error",
|
||||
"유령 로그인 ID 대상을 불러오지 못했습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{deleteMutation.data ? (
|
||||
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{t(
|
||||
"msg.admin.integrity.orphan_login_ids.delete_success",
|
||||
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
||||
{ count: deleteMutation.data.deletedCount },
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<OrphanLoginIDTable
|
||||
items={orphanItems}
|
||||
selectedIds={selectedOrphanIds}
|
||||
onToggle={toggleOrphanID}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataIntegrityPage() {
|
||||
return (
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.integrity.forbidden.description",
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<DataIntegrityContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
85
adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx
Normal file
85
adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchMe,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import OrySSOTPage from "./OrySSOTPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchOrySSOTSystemStatus: vi.fn(async () => ({
|
||||
identityCache: {
|
||||
status: "ready",
|
||||
redisReady: true,
|
||||
observedCount: 151,
|
||||
keyCount: 153,
|
||||
lastRefreshedAt: "2026-05-11T03:00:00Z",
|
||||
updatedAt: "2026-05-11T03:00:10Z",
|
||||
},
|
||||
})),
|
||||
flushIdentityCache: vi.fn(async () => ({
|
||||
status: "success",
|
||||
flushedKeys: 153,
|
||||
updatedAt: "2026-05-11T03:02:00Z",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OrySSOTPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OrySSOTPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
});
|
||||
|
||||
it("renders identity cache status and flushes cache", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText("Ory SSOT 시스템")).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("151")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(fetchMe).toHaveBeenCalled();
|
||||
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
238
adminfront/src/features/ory-ssot/OrySSOTPage.tsx
Normal file
238
adminfront/src/features/ory-ssot/OrySSOTPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Database, Trash2 } from "lucide-react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { getAdminDateLocale } from "../../lib/locale";
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(getAdminDateLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
|
||||
if (ready) {
|
||||
return (
|
||||
<Badge variant="success">
|
||||
{t("ui.admin.ory_ssot.status.ready", "ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<Badge variant="warning">
|
||||
{t("ui.admin.ory_ssot.status.failed", "failed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function OrySSOTContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
queryFn: fetchOrySSOTSystemStatus,
|
||||
});
|
||||
|
||||
const flushMutation = useMutation({
|
||||
mutationFn: flushIdentityCache,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["ory-ssot-system-status"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFlush = () => {
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.admin.ory_ssot.flush_confirm",
|
||||
"Flush only Redis identity cache keys?",
|
||||
),
|
||||
);
|
||||
if (confirmed) flushMutation.mutate();
|
||||
};
|
||||
|
||||
const identityCache = data?.identityCache;
|
||||
|
||||
return (
|
||||
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.ory_ssot.subtitle",
|
||||
"Review Kratos source-of-truth and Redis identity cache status separately.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleFlush}
|
||||
disabled={flushMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t(
|
||||
"ui.admin.ory_ssot.actions.flush_identity_cache",
|
||||
"Redis cache flush",
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.ory_ssot.load_error",
|
||||
"Failed to load Ory SSOT system status.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{flushMutation.data ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{t(
|
||||
"msg.admin.ory_ssot.flush_success",
|
||||
"Flushed {{count}} Redis identity cache keys.",
|
||||
{ count: flushMutation.data.flushedKeys },
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{flushMutation.error ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(flushMutation.error as Error)?.message ||
|
||||
t(
|
||||
"msg.admin.ory_ssot.flush_error",
|
||||
"Redis identity cache flush failed.",
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("ui.admin.ory_ssot.cache_card.title", "Redis identity cache")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.cache_card.description",
|
||||
"Redis mirror/cache status for Kratos identity list and lookup operations.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.loading", "Loading")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<StatusBadge
|
||||
ready={
|
||||
Boolean(identityCache?.redisReady) &&
|
||||
identityCache?.status === "ready"
|
||||
}
|
||||
status={identityCache?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.summary.observed_identities",
|
||||
"Observed identities",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{identityCache?.observedCount ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.cache_keys", "Cache keys")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{identityCache?.keyCount ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.summary.last_refreshed",
|
||||
"Last refreshed",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(identityCache?.lastRefreshedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{identityCache?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{identityCache.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrySSOTPage() {
|
||||
return (
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("ui.admin.ory_ssot.forbidden.title", "Access denied")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.ory_ssot.forbidden.description",
|
||||
"This screen is only available to super_admin users.",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<OrySSOTContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
266
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
266
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
totalUsers: 152,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥그룹",
|
||||
slug: "hanmac-group",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "한맥",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "org-1",
|
||||
type: "ORGANIZATION",
|
||||
name: "개발팀",
|
||||
slug: "dev-team",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "personal-1",
|
||||
type: "PERSONAL",
|
||||
name: "개인",
|
||||
slug: "personal",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 4,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
period: "day",
|
||||
items: [
|
||||
{
|
||||
date: "2026-05-05",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "orgfront",
|
||||
clientName: "OrgFront",
|
||||
loginRequests: 12,
|
||||
otherRequests: 4,
|
||||
uniqueSubjects: 8,
|
||||
},
|
||||
{
|
||||
date: "2026-05-06",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "adminfront",
|
||||
clientName: "AdminFront",
|
||||
loginRequests: 7,
|
||||
otherRequests: 3,
|
||||
uniqueSubjects: 5,
|
||||
},
|
||||
{
|
||||
date: "2026-09-28",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "devfront",
|
||||
clientName: "DevFront",
|
||||
loginRequests: 2,
|
||||
otherRequests: 1,
|
||||
uniqueSubjects: 2,
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchDataIntegrityReport: vi.fn(async () => ({
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 5,
|
||||
passed: 4,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
},
|
||||
{
|
||||
key: "user_integrity",
|
||||
label: "사용자 정합성",
|
||||
status: "fail",
|
||||
checks: [],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders usage trend chart without quick navigation or permission checker", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.05")).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.06")).toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview tenant count from the fully fetched tenant list", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("4");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||
"152",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
});
|
||||
|
||||
it("limits the overview graph choices to company tenants", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted company", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("한맥그룹 (hanmac-group)"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the latest integrity summary at the bottom for super admins only", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
|
||||
expect(screen.getByText("실패 1건")).toBeInTheDocument();
|
||||
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch or show the integrity summary for non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||
renderWithProviders(<AuthPage />);
|
||||
|
||||
expect(screen.getByText("인증 가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("IDP session placeholder"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,200 +1,607 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Key,
|
||||
PlusCircle,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
type DataIntegrityStatus,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
return (
|
||||
<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-muted-foreground">
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
};
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
const byDate = new Map<string, DailyPoint>();
|
||||
for (const row of rows) {
|
||||
const current =
|
||||
byDate.get(row.date) ??
|
||||
({
|
||||
date: row.date,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
} satisfies DailyPoint);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
byDate.set(row.date, current);
|
||||
}
|
||||
return Array.from(byDate.values()).sort((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = row.clientId;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function parseDateParts(date: string) {
|
||||
const parts = date.split("-");
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
year: Number(parts[0]),
|
||||
month: Number(parts[1]),
|
||||
day: Number(parts[2]),
|
||||
monthText: parts[1],
|
||||
dayText: parts[2],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getISOWeekNumber(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
function getISOWeekThursday(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
const parts = parseDateParts(date);
|
||||
if (!parts) {
|
||||
return date;
|
||||
}
|
||||
if (period === "month") {
|
||||
return `${parts.monthText}월`;
|
||||
}
|
||||
if (period === "week") {
|
||||
const weekNumber = String(
|
||||
getISOWeekNumber(parts.year, parts.month, parts.day),
|
||||
).padStart(2, "0");
|
||||
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
|
||||
const weekMonth = weekThursday.getUTCMonth() + 1;
|
||||
const weekDay = weekThursday.getUTCDate();
|
||||
const weekMonthText = String(weekMonth).padStart(2, "0");
|
||||
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
|
||||
return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`;
|
||||
}
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
function formatOverviewDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function integrityStatusText(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
default:
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
}
|
||||
}
|
||||
|
||||
function integrityStatusClass(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
case "warning":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
default:
|
||||
return "text-destructive";
|
||||
}
|
||||
}
|
||||
|
||||
function IntegrityOverviewSummary() {
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["admin-overview-integrity"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle size={16} />
|
||||
<span>
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
"ui.admin.integrity.fetch_error",
|
||||
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.status === "pass" ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`font-semibold ${integrityStatusClass(data.status)}`}
|
||||
>
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
|
||||
count: data.summary.failures,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
{data.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||
>
|
||||
<span>{integritySectionLabel(section.key, section.label)}</span>
|
||||
<span
|
||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||
>
|
||||
{integrityStatusText(section.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
periodControls,
|
||||
filters,
|
||||
period,
|
||||
}: {
|
||||
rows: RPUsageDailyMetric[];
|
||||
periodControls: ReactNode;
|
||||
filters: ReactNode;
|
||||
period: RPUsagePeriod;
|
||||
}) {
|
||||
const daily = summarizeDaily(rows);
|
||||
const series = summarizeSeries(rows);
|
||||
const chartWidth = 720;
|
||||
const chartHeight = 230;
|
||||
const padX = 48;
|
||||
const padTop = 32;
|
||||
const padBottom = 34;
|
||||
const innerWidth = chartWidth - padX * 2;
|
||||
const innerHeight = chartHeight - padTop - padBottom;
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...daily.map((point) => point.loginRequests + point.otherRequests),
|
||||
...daily.map((point) => point.loginRequests),
|
||||
);
|
||||
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
|
||||
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
|
||||
const y = (value: number) =>
|
||||
padTop + innerHeight - (value / maxValue) * innerHeight;
|
||||
const x = (index: number) => padX + slot * index + slot / 2;
|
||||
const linePoints = daily
|
||||
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{periodControls}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
활성화된 테넌트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
{filters}
|
||||
|
||||
<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">
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
"ui.common.chart.series_summary.login_users",
|
||||
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||
{
|
||||
login: item.loginRequests.toLocaleString(),
|
||||
subjects: item.uniqueSubjects.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
queryFn: fetchAdminOverviewStats,
|
||||
retry: false,
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const filteredUsageRows = useMemo(() => {
|
||||
if (selectedTenantIds.length === 0) {
|
||||
return usageRows;
|
||||
}
|
||||
const selectedSet = new Set(selectedTenantIds);
|
||||
return usageRows.filter((row) => selectedSet.has(row.tenantId));
|
||||
}, [selectedTenantIds, usageRows]);
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", t("ui.common.chart.period.day", "일")],
|
||||
["week", t("ui.common.chart.period.week", "주")],
|
||||
["month", t("ui.common.chart.period.month", "월")],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
const chartFilters = (
|
||||
<div>
|
||||
<OverviewSelectionChips
|
||||
allLabel="전체"
|
||||
options={tenantOptions.map((tenant) => ({
|
||||
id: tenant.id,
|
||||
label: `${tenant.name} (${tenant.slug})`,
|
||||
}))}
|
||||
selectedIds={selectedTenantIds}
|
||||
onSelectAll={() => setSelectedTenantIds([])}
|
||||
onToggle={(tenantId) => {
|
||||
setSelectedTenantIds((current) =>
|
||||
current.includes(tenantId)
|
||||
? current.filter((item) => item !== tenantId)
|
||||
: [...current, tenantId],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<LayoutDashboard size={20} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"최근 감사 로그 (24h)",
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
발생한 이벤트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.oidc_clients",
|
||||
"OIDC 클라이언트",
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
|
||||
value={metric(stats?.totalUsers)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"24시간 이벤트",
|
||||
)}
|
||||
value={metric(stats?.auditEvents24h)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Database size={14} />}
|
||||
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
value="Active"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
{t(
|
||||
"ui.admin.overview.chart.title",
|
||||
"회사별 앱별 로그인 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{periodControls}
|
||||
</div>
|
||||
{chartFilters}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
그래프가 표시됩니다.
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={filteredUsageRows}
|
||||
periodControls={periodControls}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<div className="pt-4">
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
<IntegrityOverviewSummary />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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,50 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DomainTagInput } from "./DomainTagInput";
|
||||
|
||||
describe("DomainTagInput", () => {
|
||||
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
|
||||
const onChange = vi.fn();
|
||||
const onConfirmedConflictsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DomainTagInput
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
tenants={[
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["samaneng.com"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]}
|
||||
currentTenantId="tenant-2"
|
||||
confirmedConflicts={[]}
|
||||
onConfirmedConflictsChange={onConfirmedConflictsChange}
|
||||
placeholder="example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("example.com");
|
||||
fireEvent.change(input, { target: { value: "samaneng.com" } });
|
||||
fireEvent.keyDown(input, { key: " " });
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계속 진행" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
});
|
||||
});
|
||||
192
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
192
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type DomainConflict,
|
||||
findDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
normalizeDomainTokens,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
type DomainTagInputProps = {
|
||||
id?: string;
|
||||
value: string[];
|
||||
onChange: (domains: string[]) => void;
|
||||
tenants?: TenantSummary[];
|
||||
currentTenantId?: string;
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
tenants = [],
|
||||
currentTenantId,
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const addConfirmedConflict = (domain: string) => {
|
||||
if (!confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.([...confirmedConflicts, domain]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeConfirmedConflict = (domain: string) => {
|
||||
if (confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.(
|
||||
confirmedConflicts.filter((item) => item !== domain),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const addDomain = (domain: string, confirmed = false) => {
|
||||
if (value.includes(domain)) {
|
||||
return;
|
||||
}
|
||||
onChange([...value, domain]);
|
||||
if (confirmed) {
|
||||
addConfirmedConflict(domain);
|
||||
}
|
||||
};
|
||||
|
||||
const tokenizeInput = () => {
|
||||
const tokens = normalizeDomainTokens(input);
|
||||
if (tokens.length === 0) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (value.includes(token)) {
|
||||
continue;
|
||||
}
|
||||
const conflict = findDomainConflict(token, tenants, currentTenantId);
|
||||
if (conflict && !confirmedConflicts.includes(token)) {
|
||||
setPendingConflict(conflict);
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
addDomain(token, confirmedConflicts.includes(token));
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const removeDomain = (domain: string) => {
|
||||
onChange(value.filter((item) => item !== domain));
|
||||
removeConfirmedConflict(domain);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
|
||||
{value.map((domain) => (
|
||||
<Badge
|
||||
key={domain}
|
||||
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
id={id}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onBlur={tokenizeInput}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === " " ||
|
||||
event.key === "Enter" ||
|
||||
event.key === "," ||
|
||||
event.key === ";"
|
||||
) {
|
||||
event.preventDefault();
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingConflict !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingConflict(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingConflict
|
||||
? t(
|
||||
"ui.admin.tenants.domain_conflict.description",
|
||||
formatDomainConflictMessage(pendingConflict),
|
||||
)
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPendingConflict(null)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pendingConflict) {
|
||||
addDomain(pendingConflict.domain, true);
|
||||
}
|
||||
setPendingConflict(null);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.continue", "계속 진행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
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,21 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
|
||||
|
||||
export function filterParentTenants(
|
||||
tenants: TenantSummary[],
|
||||
search: string,
|
||||
companyOnly: boolean,
|
||||
excludeTenantId = "",
|
||||
) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return tenants.filter((tenant) => {
|
||||
if (excludeTenantId && tenant.id === excludeTenantId) return false;
|
||||
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [tenant.name, tenant.slug, tenant.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { ParentTenantSelector } from "./ParentTenantSelector";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "Saman Engineering",
|
||||
slug: "saman",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("ParentTenantSelector picker", () => {
|
||||
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
const pickerSrc = screen
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(pickerSrc).toContain("http://localhost:5175/login");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "company-1",
|
||||
name: "Saman Engineering",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
|
||||
});
|
||||
|
||||
it("scopes the org-chart picker to the requested tenant root", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
orgChartTenantId="group-1"
|
||||
orgChartPickerLabel="한맥가족에서 선택"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
|
||||
|
||||
const pickerSrc = screen
|
||||
.getByTestId("parent-tenant-picker-frame")
|
||||
.getAttribute("src");
|
||||
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
|
||||
});
|
||||
|
||||
it("keeps the current tenant out of picker message selections", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
excludeTenantId="company-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "company-1",
|
||||
name: "Saman Engineering",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("selects a non-hanmac parent from the local tenant picker", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ParentTenantSelector
|
||||
id="parentId"
|
||||
label="상위 테넌트"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
tenants={tenants}
|
||||
noneLabel="없음"
|
||||
orgChartPickerLabel="한맥가족에서 선택"
|
||||
localPickerLabel="다른 테넌트 선택"
|
||||
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
|
||||
{ target: { value: "saman" } },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("company-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
const tenants: TenantSummary[] = [
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "Saman Engineering",
|
||||
slug: "saman",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "group-1",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "org-1",
|
||||
type: "ORGANIZATION",
|
||||
name: "기획부",
|
||||
slug: "planning",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("filterParentTenants", () => {
|
||||
it("searches parent candidates by name and slug", () => {
|
||||
expect(
|
||||
filterParentTenants(tenants, "saman", false).map((t) => t.id),
|
||||
).toEqual(["company-1"]);
|
||||
expect(
|
||||
filterParentTenants(tenants, "family", false).map((t) => t.id),
|
||||
).toEqual(["group-1"]);
|
||||
});
|
||||
|
||||
it("can limit parent candidates to company and company group tenants", () => {
|
||||
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
|
||||
"company-1",
|
||||
"group-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Building2, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { filterParentTenants } from "./ParentTenantSelector.helpers";
|
||||
|
||||
type ParentTenantSelectorProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
tenants: TenantSummary[];
|
||||
noneLabel: string;
|
||||
helpText?: string;
|
||||
excludeTenantId?: string;
|
||||
labelAction?: ReactNode;
|
||||
contextLabel?: string;
|
||||
orgChartPickerLabel?: string;
|
||||
orgChartTenantId?: string;
|
||||
localPickerLabel?: string;
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
tenants,
|
||||
noneLabel,
|
||||
helpText,
|
||||
excludeTenantId,
|
||||
labelAction,
|
||||
contextLabel,
|
||||
orgChartPickerLabel,
|
||||
orgChartTenantId,
|
||||
localPickerLabel,
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
disabled = false,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const selectedTenant = tenants.find((tenant) => tenant.id === value);
|
||||
const localCandidates = filterParentTenants(
|
||||
localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
|
||||
localSearch,
|
||||
false,
|
||||
excludeTenantId,
|
||||
);
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
{
|
||||
tenantId: orgChartTenantId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
if (excludeTenantId && selection.id === excludeTenantId) return;
|
||||
|
||||
onChange(selection.id);
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [excludeTenantId, onChange, pickerOpen]);
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-1" : "space-y-2"}>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
|
||||
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
|
||||
}
|
||||
>
|
||||
<Label className="text-sm font-semibold">{label}</Label>
|
||||
{labelAction}
|
||||
</div>
|
||||
<input id={id} name={id} type="hidden" value={value} readOnly />
|
||||
<div
|
||||
data-testid={controlTestId}
|
||||
className={
|
||||
compact
|
||||
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
|
||||
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
|
||||
}
|
||||
>
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
(compact ? undefined : selectedTenant?.name) ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.picker_description",
|
||||
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="parent-tenant-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{localPickerLabel && (
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{localPickerLabel ??
|
||||
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_description",
|
||||
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
id="parent-tenant-local-search"
|
||||
name="parent-tenant-local-search"
|
||||
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={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.parent.local_search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색",
|
||||
)}
|
||||
/>
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
{localCandidates.map((tenant) => (
|
||||
<Button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
onChange(tenant.id);
|
||||
setLocalPickerOpen(false);
|
||||
setLocalSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tenant.slug} · {tenant.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{localCandidates.length === 0 && (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.parent.local_picker_empty",
|
||||
"선택할 수 있는 테넌트가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{selectedTenant ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
compact
|
||||
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||
>
|
||||
{compact
|
||||
? `${selectedTenant.name} · ${selectedTenant.slug}`
|
||||
: `${selectedTenant.slug} · ${selectedTenant.type}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
compact
|
||||
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{noneLabel}
|
||||
</span>
|
||||
)}
|
||||
{contextLabel && (
|
||||
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{helpText && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
type TenantPermissionKey,
|
||||
useTenantPermission,
|
||||
} from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantPermissionGuardProps {
|
||||
tenantId: string;
|
||||
relation: TenantPermissionKey;
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TenantPermissionGuard({
|
||||
tenantId,
|
||||
relation,
|
||||
fallback = null,
|
||||
children,
|
||||
}: TenantPermissionGuardProps) {
|
||||
const { hasPermission, isLoading } = useTenantPermission(tenantId);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!hasPermission(relation)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
type TenantSummary,
|
||||
type UserProfileResponse,
|
||||
} from "../../../lib/adminApi";
|
||||
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||
import { useTenantPermission } from "./useTenantPermission";
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(),
|
||||
fetchTenant: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function mockProfile(
|
||||
overrides: Partial<UserProfileResponse>,
|
||||
): UserProfileResponse {
|
||||
return {
|
||||
id: "user-id",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
phone: "",
|
||||
role: "user",
|
||||
department: "",
|
||||
affiliationType: "general",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockTenant(overrides: Partial<TenantSummary>): TenantSummary {
|
||||
return {
|
||||
id: "tenant-id",
|
||||
type: "COMPANY",
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTenantPermission", () => {
|
||||
it("returns true for all permissions if user is super_admin", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue(
|
||||
mockProfile({
|
||||
id: "user-super",
|
||||
role: "super_admin",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue(
|
||||
mockTenant({
|
||||
id: "tenant-1",
|
||||
name: "Super Tenant",
|
||||
userPermissions: { view: false, manage: false, manage_admins: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue(
|
||||
mockProfile({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue(
|
||||
mockTenant({
|
||||
id: "tenant-2",
|
||||
name: "Tenant Admin Corp",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenantPermissionGuard", () => {
|
||||
it("renders children when user has permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue(
|
||||
mockProfile({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue(
|
||||
mockTenant({
|
||||
id: "tenant-3",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard
|
||||
tenantId="tenant-3"
|
||||
relation="manage"
|
||||
fallback={<div>Access Denied</div>}
|
||||
>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Granted")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback when user lacks permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue(
|
||||
mockProfile({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue(
|
||||
mockTenant({
|
||||
id: "tenant-4",
|
||||
userPermissions: { view: true, manage: false, manage_admins: false },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard
|
||||
tenantId="tenant-4"
|
||||
relation="manage"
|
||||
fallback={<div>Access Denied</div>}
|
||||
>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Denied")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
41
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
41
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export type TenantPermissionKey =
|
||||
| "view"
|
||||
| "manage"
|
||||
| "manage_admins"
|
||||
| "view_profile"
|
||||
| "manage_profile"
|
||||
| "view_permissions"
|
||||
| "manage_permissions"
|
||||
| "view_organization"
|
||||
| "manage_organization"
|
||||
| "view_schema"
|
||||
| "manage_schema"
|
||||
| "view_worksmobile"
|
||||
| "manage_worksmobile";
|
||||
|
||||
export function useTenantPermission(tenantId: string) {
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const hasPermission = (requiredRelation: TenantPermissionKey): boolean => {
|
||||
// Super Admin always has full bypass access
|
||||
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||
return true;
|
||||
}
|
||||
return !!tenant?.userPermissions?.[requiredRelation];
|
||||
};
|
||||
|
||||
return { hasPermission, isLoading: !tenant };
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
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 { useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
@@ -48,20 +46,39 @@ import {
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
removeTenantOwner,
|
||||
type TenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
function mergePendingMembers(
|
||||
members: TenantAdmin[],
|
||||
pendingMembers: TenantAdmin[],
|
||||
) {
|
||||
const existingIds = new Set(members.map((member) => member.id));
|
||||
return [
|
||||
...members,
|
||||
...pendingMembers.filter((member) => !existingIds.has(member.id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const _currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable =
|
||||
hasPermission("manage_permissions") || hasPermission("manage_admins");
|
||||
const canView = hasPermission("view_permissions") || hasPermission("view");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
|
||||
if (!tenantId) return null;
|
||||
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||
|
||||
const ownersQuery = useQuery({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
@@ -95,18 +112,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
// Optimistically add to the list to prevent immediate double clicks
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticOwner = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingOwners((old) =>
|
||||
old.some((owner) => owner.id === userId)
|
||||
? old
|
||||
: [...old, optimisticOwner],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticOwner];
|
||||
if (old.some((o) => o.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticOwner];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -125,6 +146,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -148,6 +170,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-owners",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-owners", tenantId],
|
||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||
@@ -167,7 +190,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -194,18 +217,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const addedUser = searchResults.find((u) => u.id === userId);
|
||||
if (addedUser) {
|
||||
const optimisticAdmin = {
|
||||
id: userId,
|
||||
name: addedUser.name,
|
||||
email: addedUser.email,
|
||||
};
|
||||
setPendingAdmins((old) =>
|
||||
old.some((admin) => admin.id === userId)
|
||||
? old
|
||||
: [...old, optimisticAdmin],
|
||||
);
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => {
|
||||
if (!old)
|
||||
return [
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
if (!old) return [optimisticAdmin];
|
||||
if (old.some((a) => a.id === userId)) return old;
|
||||
return [
|
||||
...old,
|
||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
||||
];
|
||||
return [...old, optimisticAdmin];
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -223,6 +250,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
setSearchTerm("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -246,6 +274,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
"tenant-admins",
|
||||
tenantId,
|
||||
]);
|
||||
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||
queryClient.setQueryData<TenantAdmin[]>(
|
||||
["tenant-admins", tenantId],
|
||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||
@@ -262,7 +291,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -284,7 +313,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOwner = (userId: string, userName: string) => {
|
||||
const _handleRemoveOwner = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -298,7 +327,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
const _handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -312,8 +341,22 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentOwners = ownersQuery.data || [];
|
||||
const currentAdmins = adminsQuery.data || [];
|
||||
if (!tenantId) return null;
|
||||
|
||||
if (!canView) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
const isDialogOpen = dialogMode !== null;
|
||||
|
||||
@@ -334,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||
<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)]">
|
||||
@@ -354,6 +397,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
@@ -363,7 +407,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
@@ -371,22 +415,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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">
|
||||
<TableCell colSpan={2} 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}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -404,7 +445,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${owner.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -417,46 +459,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
@@ -485,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
@@ -494,7 +497,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
@@ -502,22 +505,19 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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">
|
||||
<TableCell colSpan={2} 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}
|
||||
colSpan={2}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -535,7 +535,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
||||
onClick={() => navigate(`/users/${admin.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -548,46 +549,6 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user