chore: import codes
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# CORE
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_URL_DEV=http://localhost:3000
|
||||
ZEN_NAME=ZEN
|
||||
ZEN_DESCRIPTION=
|
||||
|
||||
# CONFIG
|
||||
ZEN_TIMEZONE=America/Toronto
|
||||
ZEN_DATE_FORMAT=YYYY-MM-DD
|
||||
ZEN_CURRENCY=CAD
|
||||
ZEN_CURRENCY_SYMBOL=$
|
||||
ZEN_SUPPORT_EMAIL=support@exemple.com
|
||||
|
||||
# DATABASE
|
||||
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
||||
# Used when NODE_ENV=development (falls back to ZEN_DATABASE_URL if unset)
|
||||
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
||||
|
||||
# STORAGE (Cloudflare R2 for now)
|
||||
ZEN_STORAGE_BUCKET=my-bucket-name
|
||||
ZEN_STORAGE_REGION=your-account-id
|
||||
ZEN_STORAGE_ACCESS_KEY=
|
||||
ZEN_STORAGE_SECRET_KEY=
|
||||
|
||||
# EMAIL
|
||||
ZEN_EMAIL_RESEND_APIKEY=
|
||||
ZEN_EMAIL_FROM_NAME="EXEMPLE"
|
||||
ZEN_EMAIL_FROM_ADDRESS=app@exemple.com
|
||||
ZEN_EMAIL_LOGO=
|
||||
ZEN_EMAIL_LOGO_URL=
|
||||
|
||||
# STRIPE
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# AUTH SETTINGS
|
||||
ZEN_AUTH_REDIRECT_AFTER_LOGIN=/admin
|
||||
ZEN_AUTH_SESSION_COOKIE_NAME=zen_session
|
||||
|
||||
# PUBLIC SETTINGS
|
||||
ZEN_PUBLIC_LOGO_WHITE=
|
||||
ZEN_PUBLIC_LOGO_BLACK=
|
||||
ZEN_PUBLIC_LOGO_URL=
|
||||
|
||||
# OTHERS
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
zen
|
||||
Copyright (C) 2026 hykocx
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
zen Copyright (C) 2026 hykocx
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,21 @@
|
||||
# zen
|
||||
|
||||
Un CMS construit sur l'essentiel, rien de plus, rien de moins.
|
||||
|
||||
> [!WARNING]
|
||||
> Ce projet est en développement actif et n'est pas encore prêt pour une utilisation en production. L'API, la structure et les fonctionnalités peuvent changer à tout moment.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Système de modules dynamiques** - Créez des modules sans modifier le code principal
|
||||
- **Authentification** - Authentification et autorisation des utilisateurs intégrées
|
||||
- **Tableau de bord** - Génération automatique d'interfaces d'administrations
|
||||
- **Routeur API** - API RESTful avec authentification
|
||||
- **Système d'emails** - Templates d'emails avec React Email
|
||||
- **Stockage** - Stockage de fichiers compatible S3
|
||||
- **Paiements** - Intégration Stripe
|
||||
- **Tâches planifiées** - Gestion des tâches programmées
|
||||
|
||||
## Démarrage
|
||||
|
||||
Pour les instructions d'installation et de configuration, voir [INSTALL.md](./docs/INSTALL.md).
|
||||
@@ -1,3 +1,21 @@
|
||||
# zen
|
||||
|
||||
Un CMS construit sur l'essentiel, rien de plus, rien de moins.
|
||||
|
||||
> [!WARNING]
|
||||
> Ce projet est en développement actif et n'est pas encore prêt pour une utilisation en production. L'API, la structure et les fonctionnalités peuvent changer à tout moment.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Système de modules dynamiques** - Créez des modules sans modifier le code principal
|
||||
- **Authentification** - Authentification et autorisation des utilisateurs intégrées
|
||||
- **Tableau de bord** - Génération automatique d'interfaces d'administrations
|
||||
- **Routeur API** - API RESTful avec authentification
|
||||
- **Système d'emails** - Templates d'emails avec React Email
|
||||
- **Stockage** - Stockage de fichiers compatible S3
|
||||
- **Paiements** - Intégration Stripe
|
||||
- **Tâches planifiées** - Gestion des tâches programmées
|
||||
|
||||
## Démarrage
|
||||
|
||||
Pour les instructions d'installation et de configuration, voir [INSTALL.md](./docs/INSTALL.md).
|
||||
@@ -0,0 +1,61 @@
|
||||
# Installation
|
||||
|
||||
## 1. Install the package
|
||||
|
||||
```bash
|
||||
npm install @hykocx/zen
|
||||
```
|
||||
|
||||
## 2. Install the styles
|
||||
|
||||
Add the following line to your `globals.css` file:
|
||||
|
||||
```css
|
||||
@import '@hykocx/zen/styles/zen.css';
|
||||
```
|
||||
|
||||
## 3. Add ZenProvider to your root layout
|
||||
|
||||
Wrap your application with the `ZenProvider` in your root layout to enable toast notifications globally:
|
||||
|
||||
```javascript
|
||||
// app/layout.js
|
||||
import './globals.css';
|
||||
import { ZenProvider } from '@hykocx/zen/provider';
|
||||
|
||||
export const metadata = {
|
||||
title: 'My App',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ZenProvider>
|
||||
{children}
|
||||
</ZenProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Configure the environment variables
|
||||
|
||||
Check the [`.env.example`](.env.example) file for the required environment variables to add to your `.env` file.
|
||||
|
||||
## 5. Initialize the database
|
||||
|
||||
```bash
|
||||
npx zen-db init
|
||||
```
|
||||
|
||||
# Setup
|
||||
|
||||
## Quick Setup
|
||||
|
||||
You can create all required files with a single command:
|
||||
|
||||
```bash
|
||||
npx zen-setup init
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# GUIDE
|
||||
|
||||
## Langue du code
|
||||
|
||||
Tout ce qui est **code** est en **anglais**, sans exception :
|
||||
- Noms de fichiers (sauf dossiers de routes Next.js, voir ci-dessous)
|
||||
- Variables, fonctions, classes, composants
|
||||
- Commentaires dans le code
|
||||
- README.md
|
||||
- Props, événements, constantes, types
|
||||
- Git commit
|
||||
|
||||
## Langue du contenu affiché
|
||||
|
||||
Tout ce qui est **visible par l'utilisateur** est en **français** :
|
||||
- Textes, titres, descriptions, labels
|
||||
- Slugs et noms de dossiers qui correspondent à des routes URL
|
||||
- Documentations
|
||||
|
||||
## Messages de commit Git
|
||||
|
||||
Tous les messages de commit doivent être rédigés en **anglais**, en suivant le format conventional commits :
|
||||
|
||||
```
|
||||
<type>(<scope>): <description courte>
|
||||
```
|
||||
|
||||
Types courants : `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore`
|
||||
|
||||
Exemples :
|
||||
- `feat(auth): add OAuth2 login support`
|
||||
- `fix(api): handle null response from payment gateway`
|
||||
- `docs(guide): add git commit message conventions`
|
||||
- `chore(deps): update dependencies`
|
||||
|
||||
## Guide de rédaction
|
||||
|
||||
Se référer à `REDACTION.md` avant de rédiger tout contenu textuel.
|
||||
@@ -0,0 +1,163 @@
|
||||
# GUIDE DE RÉDACTION
|
||||
Dernière modification : 2026-04-12
|
||||
|
||||
## La voix de l'entreprise
|
||||
|
||||
On parle comme une personne, pas comme une organisation. Direct, sans fioritures.
|
||||
|
||||
On dit **"on"** — jamais "nous sommes heureux de", jamais "notre équipe d'experts". Une seule exception : quand on cite quelqu'un nommément, on peut utiliser "je".
|
||||
|
||||
La technique est là parce qu'elle est utile, pas pour impressionner. Si une explication technique ne sert pas le lecteur, elle ne sert pas le texte.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'on évite absolument
|
||||
|
||||
**Les superlatifs vides**
|
||||
*de premier plan, leader, de pointe, best-in-class, incontournable*
|
||||
|
||||
**Les promesses floues**
|
||||
*solutions sur mesure, accompagnement personnalisé, approche holistique*
|
||||
|
||||
**Le corporate**
|
||||
*nous nous engageons à, notre mission est de, dans une optique de, à cet effet*
|
||||
|
||||
**La sur-promesse**
|
||||
Si on ne peut pas le garantir, on ne l'écrit pas. Jamais.
|
||||
|
||||
**Les métaphores usées**
|
||||
*pont entre, clé en main, écosystème, synergies, à 360°*
|
||||
|
||||
**Le tiret long (—)**
|
||||
Reformuler la phrase plutôt que d'insérer une incise.
|
||||
|
||||
**Les chevilles inutiles**
|
||||
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à*
|
||||
|
||||
**Le passif sans raison**
|
||||
Préférer la forme active. "On a livré le projet" plutôt que "le projet a été livré".
|
||||
|
||||
---
|
||||
|
||||
## Formules qui marchent
|
||||
|
||||
**Plutôt ça :**
|
||||
> "On dit non quand c'est la bonne réponse."
|
||||
> "On pense à comment les choses vont tenir dans six mois."
|
||||
> "Ce n'est pas le bon projet pour nous. Voilà pourquoi."
|
||||
|
||||
**Pas ça :**
|
||||
> "Notre approche centrée client garantit des résultats optimaux."
|
||||
> "Nous mettons notre expertise au service de vos ambitions."
|
||||
> "Une équipe passionnée à votre écoute."
|
||||
|
||||
**La règle d'or :** si ça pourrait figurer dans la communication d'un concurrent sans changer un mot, c'est à réécrire.
|
||||
|
||||
---
|
||||
|
||||
## Structure des textes
|
||||
|
||||
### Titres
|
||||
Courts, affirmatifs, sans point. Une idée, pas une liste. Pas de question rhétorique.
|
||||
|
||||
> ✓ "Zéro raccourci. Zéro compromis."
|
||||
> ✓ "On livre. On reste."
|
||||
> ✗ "Une approche rigoureuse pour des résultats durables"
|
||||
> ✗ "Pourquoi nous choisir ?"
|
||||
|
||||
### Corps de texte
|
||||
Phrases courtes. Une idée par phrase. Maximum deux virgules par phrase — si on en compte plus, couper.
|
||||
|
||||
Paragraphes de trois à quatre phrases maximum. Une ligne blanche entre chaque paragraphe.
|
||||
|
||||
Pas de bullet points pour des concepts. Seulement pour des listes réelles (étapes, éléments techniques, options).
|
||||
|
||||
### Appels à l'action
|
||||
Concrets et à l'infinitif. Pas d'exclamation.
|
||||
|
||||
> ✓ "Discuter de votre projet"
|
||||
> ✓ "Voir comment on travaille"
|
||||
> ✗ "Contactez-nous pour en savoir plus !"
|
||||
> ✗ "Passez à l'action"
|
||||
|
||||
---
|
||||
|
||||
## Selon le format
|
||||
|
||||
### Site web
|
||||
Chaque page a une seule idée principale. Le visiteur doit comprendre l'essentiel en dix secondes. Les détails viennent après, pour ceux qui cherchent.
|
||||
|
||||
Pas de jargon en page d'accueil. Le jargon technique appartient aux pages de service, là où le lecteur s'y attend.
|
||||
|
||||
### Courriels
|
||||
Objet : concret, pas accrocheur. Dire ce dont il s'agit, pas promettre quelque chose.
|
||||
|
||||
> ✓ "Proposition — Refonte infrastructure"
|
||||
> ✗ "Une opportunité à ne pas manquer"
|
||||
|
||||
Corps du courriel : aller droit au but dès la première phrase. Pas de "j'espère que ce message vous trouve bien." Si on doit écrire plus de quatre paragraphes, se demander si c'est le bon format.
|
||||
|
||||
### Applications et interfaces
|
||||
Chaque message, étiquette ou instruction doit avoir un seul sens possible. Pas de formulation qui force l'utilisateur à interpréter.
|
||||
|
||||
Le ton reste humain, même dans un contexte technique. "Une erreur s'est produite" est mieux que "Erreur 403 — accès non autorisé" si le lecteur n'est pas développeur.
|
||||
|
||||
Les messages de confirmation, d'erreur et d'aide suivent les mêmes règles que le reste : courts, directs, actifs.
|
||||
|
||||
### Articles et billets
|
||||
Un angle précis, pas un survol. Mieux vaut un article sur un problème concis que dix paragraphes vagues.
|
||||
|
||||
Commencer par un fait, un constat ou une situation — pas par une définition.
|
||||
|
||||
> ✓ "La plupart des migrations ratent pour la même raison : on sous-estime ce qui dépend de ce qu'on déplace."
|
||||
> ✗ "La migration de données est un processus complexe qui nécessite une planification rigoureuse."
|
||||
|
||||
Terminer sur ce qu'on retient, pas sur une invitation à nous contacter.
|
||||
|
||||
### Propositions et soumissions
|
||||
Pas de section "qui sommes-nous" en ouverture. Le client le sait déjà ou s'en fout pour l'instant.
|
||||
|
||||
Commencer par le problème du client, tel qu'on l'a compris. S'il se reconnaît dans les deux premiers paragraphes, le reste a de la valeur.
|
||||
|
||||
La solution vient après le diagnostic. Jamais avant.
|
||||
|
||||
### Documents internes
|
||||
Mêmes règles de clarté que pour les textes externes. Un document interne mal écrit crée autant de confusion qu'une mauvaise communication client.
|
||||
|
||||
Titres de section descriptifs, pas génériques. "Décision retenue et raison" est mieux que "Résultats".
|
||||
|
||||
Si un document dépasse deux pages, se demander si l'information ne serait pas mieux transmise autrement.
|
||||
|
||||
### Réseaux sociaux
|
||||
Un seul message par publication. Si on a besoin d'un fil de publications pour expliquer, c'est probablement un article.
|
||||
|
||||
Le ton reste celui de l'entreprise — pas plus décontracté sous prétexte que c'est un réseau social.
|
||||
|
||||
Pas de hashtags décoratifs. Seulement ceux qui servent la découverte du contenu.
|
||||
|
||||
---
|
||||
|
||||
## Parler de ce qu'on fait
|
||||
|
||||
Ne jamais affirmer qu'on est "les meilleurs" ou "différents des autres". Le montrer, pas le dire.
|
||||
|
||||
Quand on parle de ce qu'on fait, on parle de situations concrètes. Pas de généralités.
|
||||
|
||||
> ✓ "On a refusé un mandat parce que le calendrier ne tenait pas."
|
||||
> ✗ "On valorise l'honnêteté dans nos relations clients."
|
||||
|
||||
---
|
||||
|
||||
## Révision d'un texte
|
||||
|
||||
Avant de publier ou d'envoyer, se poser ces questions :
|
||||
|
||||
1. **Est-ce qu'un concurrent pourrait signer ce texte ?** Si oui, réécrire.
|
||||
2. **Est-ce qu'on promet quelque chose qu'on ne contrôle pas ?** Retirer ou nuancer.
|
||||
3. **Est-ce que chaque phrase dit quelque chose ?** Supprimer celles qui ne font que meubler.
|
||||
4. **Est-ce que le lecteur sait quoi faire ou quoi penser après ?** Si non, préciser.
|
||||
5. **A-t-on utilisé le mot "solution" ?** Le remplacer. Presque toujours.
|
||||
|
||||
---
|
||||
|
||||
*Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, on l'ajuste.*
|
||||
Generated
+7598
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"name": "@hykocx/zen",
|
||||
"version": "1.3.0",
|
||||
"description": "Un CMS construit sur l'essentiel, rien de plus, rien de moins.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.hyko.cx/hykocx/zen.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.hyko.cx/api/packages/hykocx/npm/"
|
||||
},
|
||||
"license": "GPL-3.0-only",
|
||||
"author": "Hyko",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup && npm run build:css",
|
||||
"build:css": "mkdir -p ./dist/shared/styles && cp ./src/shared/styles/zen.css ./dist/shared/styles/zen.css",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"bin": {
|
||||
"zen-db": "./dist/core/database/cli.js",
|
||||
"zen-setup": "./dist/features/setup/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.0.0",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "^8.11.3",
|
||||
"react-email": "^4.3.0",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"resend": "^3.2.0",
|
||||
"stripe": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=14.0.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./auth": {
|
||||
"import": "./dist/features/auth/index.js"
|
||||
},
|
||||
"./auth/actions": {
|
||||
"import": "./dist/features/auth/actions.js"
|
||||
},
|
||||
"./auth/pages": {
|
||||
"import": "./dist/features/auth/pages.js"
|
||||
},
|
||||
"./auth/page": {
|
||||
"import": "./dist/features/auth/page.js"
|
||||
},
|
||||
"./auth/components": {
|
||||
"import": "./dist/features/auth/components/index.js"
|
||||
},
|
||||
"./admin": {
|
||||
"import": "./dist/features/admin/index.js"
|
||||
},
|
||||
"./admin/actions": {
|
||||
"import": "./dist/features/admin/actions.js"
|
||||
},
|
||||
"./admin/navigation": {
|
||||
"import": "./dist/features/admin/navigation.server.js"
|
||||
},
|
||||
"./admin/pages": {
|
||||
"import": "./dist/features/admin/pages.js"
|
||||
},
|
||||
"./admin/page": {
|
||||
"import": "./dist/features/admin/page.js"
|
||||
},
|
||||
"./api": {
|
||||
"import": "./dist/core/api/index.js"
|
||||
},
|
||||
"./zen/api": {
|
||||
"import": "./dist/core/api/nx-route.js"
|
||||
},
|
||||
"./database": {
|
||||
"import": "./dist/core/database/index.js"
|
||||
},
|
||||
"./storage": {
|
||||
"import": "./dist/core/storage/index.js"
|
||||
},
|
||||
"./email": {
|
||||
"import": "./dist/core/email/index.js"
|
||||
},
|
||||
"./email/templates": {
|
||||
"import": "./dist/core/email/templates/index.js"
|
||||
},
|
||||
"./cron": {
|
||||
"import": "./dist/core/cron/index.js"
|
||||
},
|
||||
"./stripe": {
|
||||
"import": "./dist/core/payments/stripe.js"
|
||||
},
|
||||
"./payments": {
|
||||
"import": "./dist/core/payments/index.js"
|
||||
},
|
||||
"./pdf": {
|
||||
"import": "./dist/core/pdf/index.js"
|
||||
},
|
||||
"./toast": {
|
||||
"import": "./dist/core/toast/index.js"
|
||||
},
|
||||
"./provider": {
|
||||
"import": "./dist/features/provider/index.js"
|
||||
},
|
||||
"./setup": {
|
||||
"import": "./dist/features/setup/index.js"
|
||||
},
|
||||
"./core/modules": {
|
||||
"import": "./dist/core/modules/index.js"
|
||||
},
|
||||
"./core/modules/client": {
|
||||
"import": "./dist/core/modules/client.js"
|
||||
},
|
||||
"./modules": {
|
||||
"import": "./dist/modules/index.js"
|
||||
},
|
||||
"./modules/pages": {
|
||||
"import": "./dist/modules/pages.js"
|
||||
},
|
||||
"./modules/actions": {
|
||||
"import": "./dist/modules/modules.actions.js"
|
||||
},
|
||||
"./modules/posts/crud": {
|
||||
"import": "./dist/modules/posts/crud.js"
|
||||
},
|
||||
"./modules/metadata": {
|
||||
"import": "./dist/modules/modules.metadata.js"
|
||||
},
|
||||
"./invoice/dashboard": {
|
||||
"import": "./dist/modules/invoice/dashboard/index.js"
|
||||
},
|
||||
"./nuage/dashboard": {
|
||||
"import": "./dist/modules/nuage/dashboard/index.js"
|
||||
},
|
||||
"./modules/page": {
|
||||
"import": "./dist/modules/page.js"
|
||||
},
|
||||
"./lib/metadata": {
|
||||
"import": "./dist/shared/lib/metadata/index.js"
|
||||
},
|
||||
"./components": {
|
||||
"import": "./dist/shared/components/index.js"
|
||||
},
|
||||
"./icons": {
|
||||
"import": "./dist/shared/Icons.js"
|
||||
},
|
||||
"./styles/zen.css": {
|
||||
"default": "./dist/shared/styles/zen.css"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Dynamic API Router
|
||||
* Routes incoming requests to appropriate handlers
|
||||
* Supports both core routes and dynamically discovered module routes
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
|
||||
// Core handlers
|
||||
import { handleHealth } from './handlers/health.js';
|
||||
import { handleVersion } from './handlers/version.js';
|
||||
import {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers,
|
||||
handleUpdateProfile,
|
||||
handleUpdateUserById,
|
||||
handleUploadProfilePicture,
|
||||
handleDeleteProfilePicture
|
||||
} from './handlers/users.js';
|
||||
import { handleGetFile } from './handlers/storage.js';
|
||||
import updatesHandler from './handlers/updates.js';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function requireAuth(request) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and is admin
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated and admin
|
||||
* @throws {Error} If not authenticated or not admin
|
||||
*/
|
||||
async function requireAdmin(request) {
|
||||
const session = await requireAuth(request);
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an API request to the appropriate handler
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments after /zen/api/
|
||||
* @returns {Promise<Object>} - The response data
|
||||
*/
|
||||
export async function routeRequest(request, path) {
|
||||
const method = request.method;
|
||||
|
||||
// Try core routes first
|
||||
const coreResult = await routeCoreRequest(request, path, method);
|
||||
if (coreResult !== null) {
|
||||
return coreResult;
|
||||
}
|
||||
|
||||
// Try module routes
|
||||
const moduleResult = await routeModuleRequest(request, path, method);
|
||||
if (moduleResult !== null) {
|
||||
return moduleResult;
|
||||
}
|
||||
|
||||
// No matching route
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: `No handler found for ${method} ${path.join('/')}`,
|
||||
path: path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route core (non-module) requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeCoreRequest(request, path, method) {
|
||||
// Health check endpoint
|
||||
if (path[0] === 'health' && method === 'GET') {
|
||||
return await handleHealth();
|
||||
}
|
||||
|
||||
// Version endpoint
|
||||
if (path[0] === 'version' && method === 'GET') {
|
||||
return await handleVersion();
|
||||
}
|
||||
|
||||
// Updates endpoint
|
||||
if (path[0] === 'updates' && method === 'GET') {
|
||||
return await updatesHandler(request);
|
||||
}
|
||||
|
||||
// Storage endpoint - serve files securely
|
||||
if (path[0] === 'storage' && method === 'GET') {
|
||||
const fileKey = path.slice(1).join('/');
|
||||
if (!fileKey) {
|
||||
return {
|
||||
error: 'Bad Request',
|
||||
message: 'File path is required'
|
||||
};
|
||||
}
|
||||
return await handleGetFile(request, fileKey);
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
if (path[0] === 'users') {
|
||||
// GET /zen/api/users - List all users (admin only)
|
||||
if (path.length === 1 && method === 'GET') {
|
||||
return await handleListUsers(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/me - Get current user
|
||||
if (path[1] === 'me' && method === 'GET') {
|
||||
return await handleGetCurrentUser(request);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/profile - Update current user profile
|
||||
if (path[1] === 'profile' && method === 'PUT') {
|
||||
return await handleUpdateProfile(request);
|
||||
}
|
||||
|
||||
// POST /zen/api/users/profile/picture - Upload profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') {
|
||||
return await handleUploadProfilePicture(request);
|
||||
}
|
||||
|
||||
// DELETE /zen/api/users/profile/picture - Delete profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') {
|
||||
return await handleDeleteProfilePicture(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/:id - Get user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') {
|
||||
return await handleGetUserById(request, path[1]);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/:id - Update user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') {
|
||||
return await handleUpdateUserById(request, path[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route module requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeModuleRequest(request, path, method) {
|
||||
try {
|
||||
// Import module registry
|
||||
const { getAllApiRoutes } = await import('../modules/registry.js');
|
||||
const routes = getAllApiRoutes();
|
||||
|
||||
// Convert path array to path string
|
||||
const pathString = '/' + path.join('/');
|
||||
|
||||
// Find matching route
|
||||
for (const route of routes) {
|
||||
if (matchRoute(route.path, pathString) && route.method === method) {
|
||||
// Check authentication
|
||||
try {
|
||||
if (route.auth === 'admin') {
|
||||
await requireAdmin(request);
|
||||
} else if (route.auth === 'user' || route.auth === 'auth') {
|
||||
await requireAuth(request);
|
||||
}
|
||||
// 'public' or undefined means no auth required
|
||||
|
||||
// Call the handler
|
||||
if (typeof route.handler === 'function') {
|
||||
// Extract path parameters
|
||||
const params = extractPathParams(route.path, pathString);
|
||||
return await route.handler(request, params);
|
||||
}
|
||||
} catch (authError) {
|
||||
return {
|
||||
success: false,
|
||||
error: authError.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Dynamic Router] Error routing module request:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route pattern against a path
|
||||
* Supports path parameters like :id
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {boolean} True if matches
|
||||
*/
|
||||
function matchRoute(pattern, path) {
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Skip parameter parts (they match anything)
|
||||
if (patternPart.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternPart !== pathPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from a path
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {Object} Path parameters
|
||||
*/
|
||||
function extractPathParams(pattern, path) {
|
||||
const params = {};
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code based on the response
|
||||
* @param {Object} response - The response object
|
||||
* @returns {number} - HTTP status code
|
||||
*/
|
||||
export function getStatusCode(response) {
|
||||
if (response.error) {
|
||||
switch (response.error) {
|
||||
case 'Unauthorized':
|
||||
return 401;
|
||||
case 'Forbidden':
|
||||
case 'Admin access required':
|
||||
return 403;
|
||||
case 'Not Found':
|
||||
return 404;
|
||||
case 'Bad Request':
|
||||
return 400;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
// Export auth helpers for use in module handlers
|
||||
export { requireAuth, requireAdmin };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Health Check Handler
|
||||
* Returns the status of the API and basic system information
|
||||
*/
|
||||
|
||||
export async function handleHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '0.1.0'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Storage API Handlers
|
||||
* Handles secure file access
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../../shared/lib/appConfig.js';
|
||||
import { getFile } from '@hykocx/zen/storage';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Serve a file from storage with security validation
|
||||
* @param {Request} request - The request object
|
||||
* @param {string} fileKey - The file key/path in storage
|
||||
* @returns {Promise<Response|Object>} File response or error object
|
||||
*/
|
||||
export async function handleGetFile(request, fileKey) {
|
||||
try {
|
||||
const pathParts = fileKey.split('/');
|
||||
|
||||
// Blog images: public read (no auth) for site integration
|
||||
if (pathParts[0] === 'blog') {
|
||||
const result = await getFile(fileKey);
|
||||
if (!result.success) {
|
||||
if (result.error.includes('NoSuchKey') || result.error.includes('not found')) {
|
||||
return { error: 'Not Found', message: 'File not found' };
|
||||
}
|
||||
return { error: 'Internal Server Error', message: result.error || 'Failed to retrieve file' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
lastModified: result.data.lastModified
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Require auth for other paths
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required to access files'
|
||||
};
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Security validation based on file path
|
||||
if (pathParts[0] === 'users') {
|
||||
// User files: users/{userId}/{category}/{filename}
|
||||
const userId = pathParts[1];
|
||||
|
||||
// Users can only access their own files, unless they're admin
|
||||
if (session.user.id !== userId && session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'You do not have permission to access this file'
|
||||
};
|
||||
}
|
||||
} else if (pathParts[0] === 'organizations') {
|
||||
// Organization files: organizations/{orgId}/{category}/{filename}
|
||||
// For now, only admins can access organization files
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required for organization files'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Unknown file path pattern - deny by default
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Invalid file path'
|
||||
};
|
||||
}
|
||||
|
||||
// Get file from storage
|
||||
const result = await getFile(fileKey);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error.includes('NoSuchKey') || result.error.includes('not found')) {
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: 'File not found'
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'Internal Server Error',
|
||||
message: result.error || 'Failed to retrieve file'
|
||||
};
|
||||
}
|
||||
|
||||
// Return the file data with proper headers
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
lastModified: result.data.lastModified
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error serving file:', error);
|
||||
return {
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to retrieve file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Users API Handlers
|
||||
* Handles user-related API endpoints
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { query, updateById } from '@hykocx/zen/database';
|
||||
import { getSessionCookieName, getModulesConfig } from '../../../shared/lib/appConfig.js';
|
||||
import { updateUser } from '../../../features/auth/lib/auth.js';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@hykocx/zen/storage';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Get current user information
|
||||
*/
|
||||
export async function handleGetCurrentUser(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Return user data (without sensitive information)
|
||||
return {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
role: session.user.role,
|
||||
image: session.user.image,
|
||||
emailVerified: session.user.email_verified,
|
||||
createdAt: session.user.created_at
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID (admin only)
|
||||
*/
|
||||
export async function handleGetUserById(request, userId) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
const response = { user: result.rows[0] };
|
||||
|
||||
// When clients module is active, include the client linked to this user (if any)
|
||||
const modules = getModulesConfig();
|
||||
if (modules.clients) {
|
||||
const clientResult = await query(
|
||||
`SELECT id, client_number, company_name, first_name, last_name, email
|
||||
FROM zen_clients WHERE user_id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
response.linkedClient = clientResult.rows[0] || null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user by ID (admin only)
|
||||
*/
|
||||
export async function handleUpdateUserById(request, userId) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return { success: false, error: 'Unauthorized', message: 'No session token provided' };
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Unauthorized', message: 'Invalid or expired session' };
|
||||
}
|
||||
if (session.user.role !== 'admin') {
|
||||
return { success: false, error: 'Forbidden', message: 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const allowedFields = ['name', 'role', 'email_verified'];
|
||||
const updateData = { updated_at: new Date() };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
if (field === 'email_verified') {
|
||||
updateData[field] = Boolean(body[field]);
|
||||
} else if (field === 'role') {
|
||||
const role = String(body[field]).toLowerCase();
|
||||
if (['admin', 'user'].includes(role)) {
|
||||
updateData[field] = role;
|
||||
}
|
||||
} else if (field === 'name' && body[field] != null) {
|
||||
updateData[field] = String(body[field]).trim() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateById('zen_auth_users', userId, updateData);
|
||||
if (!updated) {
|
||||
return { success: false, error: 'Not Found', message: 'User not found' };
|
||||
}
|
||||
|
||||
// When clients module is active, update client association (one user = one client)
|
||||
const modules = getModulesConfig();
|
||||
if (modules.clients && body.client_id !== undefined) {
|
||||
const clientId = body.client_id === null || body.client_id === '' ? null : parseInt(body.client_id, 10);
|
||||
// Unlink all clients currently linked to this user
|
||||
await query(
|
||||
'UPDATE zen_clients SET user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
// Link the selected client to this user if provided
|
||||
if (clientId != null && !Number.isNaN(clientId)) {
|
||||
await query(
|
||||
'UPDATE zen_clients SET user_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[userId, clientId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
user: result.rows[0],
|
||||
message: 'User updated successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to update user'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users (admin only)
|
||||
*/
|
||||
export async function handleListUsers(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get URL params for pagination and sorting
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get sorting parameters
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
// Whitelist allowed sort columns to prevent SQL injection
|
||||
const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
|
||||
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
|
||||
// Validate sort order
|
||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get users from database with dynamic sorting
|
||||
const result = await query(
|
||||
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${sortColumn} ${order} LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count
|
||||
const countResult = await query('SELECT COUNT(*) FROM zen_auth_users');
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
return {
|
||||
users: result.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user profile
|
||||
*/
|
||||
export async function handleUpdateProfile(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get update data from request body
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
// Validate input
|
||||
if (!name || !name.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'Name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
name: name.trim()
|
||||
};
|
||||
|
||||
// Update user profile
|
||||
const updatedUser = await updateUser(session.user.id, updateData);
|
||||
|
||||
// Return updated user data (without sensitive information)
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile updated successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to update profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload profile picture
|
||||
*/
|
||||
export async function handleUploadProfilePicture(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data from request
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file
|
||||
const validation = validateUpload({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.AVATAR
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: validation.errors.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
// Get current user to check for existing profile picture
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
|
||||
let oldImageKey = null;
|
||||
if (currentUser.rows.length > 0 && currentUser.rows[0].image) {
|
||||
// The image field now contains the storage key directly
|
||||
oldImageKey = currentUser.rows[0].image;
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const uniqueFilename = generateUniqueFilename(file.name, 'avatar');
|
||||
|
||||
// Generate storage path
|
||||
const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename);
|
||||
|
||||
// Convert file to buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Upload to storage
|
||||
const uploadResult = await uploadImage({
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
metadata: {
|
||||
userId: session.user.id,
|
||||
originalName: file.name
|
||||
}
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Upload Failed',
|
||||
message: uploadResult.error || 'Failed to upload image'
|
||||
};
|
||||
}
|
||||
|
||||
// Update user profile with storage key (not URL)
|
||||
const updatedUser = await updateUser(session.user.id, {
|
||||
image: key
|
||||
});
|
||||
|
||||
// Delete old image if it exists (after successful upload)
|
||||
if (oldImageKey) {
|
||||
try {
|
||||
await deleteFile(oldImageKey);
|
||||
console.log(`[ZEN] Deleted old profile picture: ${oldImageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Log error but don't fail the upload
|
||||
console.error('[ZEN] Failed to delete old profile picture:', deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture uploaded successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error uploading profile picture:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to upload profile picture'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete profile picture
|
||||
*/
|
||||
export async function handleDeleteProfilePicture(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user to check for existing profile picture
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
|
||||
if (currentUser.rows.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not Found',
|
||||
message: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
const imageKey = currentUser.rows[0].image;
|
||||
if (!imageKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'No profile picture to delete'
|
||||
};
|
||||
}
|
||||
|
||||
// Update user profile to remove image URL
|
||||
const updatedUser = await updateUser(session.user.id, {
|
||||
image: null
|
||||
});
|
||||
|
||||
// Delete image from storage
|
||||
if (imageKey) {
|
||||
try {
|
||||
await deleteFile(imageKey);
|
||||
console.log(`[ZEN] Deleted profile picture: ${imageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Log error but don't fail the update
|
||||
console.error('[ZEN] Failed to delete profile picture from storage:', deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile picture:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to delete profile picture'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Version Handler
|
||||
* Returns version information about the ZEN API
|
||||
*/
|
||||
|
||||
import { getAppName } from '../../../shared/lib/appConfig.js';
|
||||
|
||||
export async function handleVersion() {
|
||||
return {
|
||||
name: 'ZEN API',
|
||||
appName: getAppName(),
|
||||
version: '0.1.0',
|
||||
apiVersion: '1.0',
|
||||
description: 'ZEN API - Complete modular web platform'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Zen API Module
|
||||
*
|
||||
* This module exports API utilities for custom handlers
|
||||
* For route setup, import from '@hykocx/zen/zen/api'
|
||||
*/
|
||||
|
||||
// Export router utilities (for custom handlers)
|
||||
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Export individual handlers (for custom usage)
|
||||
export { handleHealth } from './handlers/health.js';
|
||||
export { handleVersion } from './handlers/version.js';
|
||||
export {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers
|
||||
} from './handlers/users.js';
|
||||
|
||||
// Module API handlers are now self-contained in their respective modules
|
||||
// e.g., invoice handlers are in @hykocx/zen/modules/invoice/api
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* ZEN API Route Handler
|
||||
*
|
||||
* This is the main catch-all route handler for the ZEN API under /zen/api/.
|
||||
* It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { routeRequest, getStatusCode } from './router.js';
|
||||
|
||||
/**
|
||||
* Handle GET requests
|
||||
*/
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
|
||||
// Check if this is a file response (from storage endpoint)
|
||||
if (response.success && response.file) {
|
||||
const headers = {
|
||||
'Content-Type': response.file.contentType || 'application/octet-stream',
|
||||
'Content-Length': response.file.contentLength?.toString() || '',
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(),
|
||||
};
|
||||
if (response.file.filename) {
|
||||
const encoded = encodeURIComponent(response.file.filename);
|
||||
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
return new NextResponse(response.file.body, { status: 200, headers });
|
||||
}
|
||||
|
||||
// Regular JSON response
|
||||
const statusCode = getStatusCode(response);
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST requests
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests
|
||||
*/
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DELETE requests
|
||||
*/
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PATCH requests
|
||||
*/
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* API Router
|
||||
* Routes incoming requests to appropriate handlers
|
||||
*
|
||||
* This router supports both:
|
||||
* - Core routes (health, version, users, storage)
|
||||
* - Module routes (imported directly from module api.js files)
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
import { getAllApiRoutes } from '../modules/index.js';
|
||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
||||
|
||||
// Core handlers
|
||||
import { handleHealth } from './handlers/health.js';
|
||||
import { handleVersion } from './handlers/version.js';
|
||||
import {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers,
|
||||
handleUpdateProfile,
|
||||
handleUpdateUserById,
|
||||
handleUploadProfilePicture,
|
||||
handleDeleteProfilePicture
|
||||
} from './handlers/users.js';
|
||||
import { handleGetFile } from './handlers/storage.js';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Get all module routes from the dynamic module registry
|
||||
* @returns {Array} Array of route definitions
|
||||
*/
|
||||
function getModuleRoutes() {
|
||||
// Use the dynamic module registry to get all routes
|
||||
return getAllApiRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function requireAuth(request) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and is admin
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated and admin
|
||||
* @throws {Error} If not authenticated or not admin
|
||||
*/
|
||||
async function requireAdmin(request) {
|
||||
const session = await requireAuth(request);
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an API request to the appropriate handler
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments after /zen/api/
|
||||
* @returns {Promise<Object>} - The response data
|
||||
*/
|
||||
export async function routeRequest(request, path) {
|
||||
const method = request.method;
|
||||
|
||||
// Global IP-based rate limit for all API calls (health/version are exempt)
|
||||
const isExempt = (path[0] === 'health' || path[0] === 'version') && method === 'GET';
|
||||
if (!isExempt) {
|
||||
const ip = getIpFromRequest(request);
|
||||
const rl = checkRateLimit(ip, 'api');
|
||||
if (!rl.allowed) {
|
||||
return {
|
||||
error: 'Too Many Requests',
|
||||
message: `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try core routes first
|
||||
const coreResult = await routeCoreRequest(request, path, method);
|
||||
if (coreResult !== null) {
|
||||
return coreResult;
|
||||
}
|
||||
|
||||
// Try module routes (dynamically discovered)
|
||||
const moduleResult = await routeModuleRequest(request, path, method);
|
||||
if (moduleResult !== null) {
|
||||
return moduleResult;
|
||||
}
|
||||
|
||||
// No matching route
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: `No handler found for ${method} ${path.join('/')}`,
|
||||
path: path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route core (non-module) requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeCoreRequest(request, path, method) {
|
||||
// Health check endpoint
|
||||
if (path[0] === 'health' && method === 'GET') {
|
||||
return await handleHealth();
|
||||
}
|
||||
|
||||
// Version endpoint
|
||||
if (path[0] === 'version' && method === 'GET') {
|
||||
return await handleVersion();
|
||||
}
|
||||
|
||||
// Storage endpoint - serve files securely
|
||||
if (path[0] === 'storage' && method === 'GET') {
|
||||
const fileKey = path.slice(1).join('/');
|
||||
if (!fileKey) {
|
||||
return {
|
||||
error: 'Bad Request',
|
||||
message: 'File path is required'
|
||||
};
|
||||
}
|
||||
return await handleGetFile(request, fileKey);
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
if (path[0] === 'users') {
|
||||
// GET /zen/api/users - List all users (admin only)
|
||||
if (path.length === 1 && method === 'GET') {
|
||||
return await handleListUsers(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/me - Get current user
|
||||
if (path[1] === 'me' && method === 'GET') {
|
||||
return await handleGetCurrentUser(request);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/profile - Update current user profile
|
||||
if (path[1] === 'profile' && method === 'PUT') {
|
||||
return await handleUpdateProfile(request);
|
||||
}
|
||||
|
||||
// POST /zen/api/users/profile/picture - Upload profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') {
|
||||
return await handleUploadProfilePicture(request);
|
||||
}
|
||||
|
||||
// DELETE /zen/api/users/profile/picture - Delete profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') {
|
||||
return await handleDeleteProfilePicture(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/:id - Get user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') {
|
||||
return await handleGetUserById(request, path[1]);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/:id - Update user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') {
|
||||
return await handleUpdateUserById(request, path[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route module requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeModuleRequest(request, path, method) {
|
||||
// Get routes from enabled modules
|
||||
const routes = getModuleRoutes();
|
||||
|
||||
// Convert path array to path string
|
||||
const pathString = '/' + path.join('/');
|
||||
|
||||
// Find matching route
|
||||
for (const route of routes) {
|
||||
if (matchRoute(route.path, pathString) && route.method === method) {
|
||||
// Check authentication
|
||||
try {
|
||||
if (route.auth === 'admin') {
|
||||
await requireAdmin(request);
|
||||
} else if (route.auth === 'user' || route.auth === 'auth') {
|
||||
await requireAuth(request);
|
||||
}
|
||||
// 'public' or undefined means no auth required
|
||||
|
||||
// Call the handler
|
||||
if (typeof route.handler === 'function') {
|
||||
// Extract path parameters
|
||||
const params = extractPathParams(route.path, pathString);
|
||||
return await route.handler(request, params);
|
||||
}
|
||||
} catch (authError) {
|
||||
return {
|
||||
success: false,
|
||||
error: authError.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route pattern against a path
|
||||
* Supports path parameters like :id
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {boolean} True if matches
|
||||
*/
|
||||
function matchRoute(pattern, path) {
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Skip parameter parts (they match anything)
|
||||
if (patternPart.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternPart !== pathPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from a path
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {Object} Path parameters
|
||||
*/
|
||||
function extractPathParams(pattern, path) {
|
||||
const params = {};
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code based on the response
|
||||
* @param {Object} response - The response object
|
||||
* @returns {number} - HTTP status code
|
||||
*/
|
||||
export function getStatusCode(response) {
|
||||
if (response.error) {
|
||||
switch (response.error) {
|
||||
case 'Unauthorized':
|
||||
return 401;
|
||||
case 'Forbidden':
|
||||
case 'Admin access required':
|
||||
return 403;
|
||||
case 'Not Found':
|
||||
return 404;
|
||||
case 'Bad Request':
|
||||
return 400;
|
||||
case 'Too Many Requests':
|
||||
return 429;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
// Export auth helpers for use in module handlers
|
||||
export { requireAuth, requireAdmin };
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Cron Utility
|
||||
* Wrapper around node-cron for scheduling tasks
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { schedule, validate } from '@hykocx/zen/cron';
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Store for all scheduled cron jobs
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize cron jobs storage
|
||||
*/
|
||||
function getJobsStorage() {
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
globalThis[CRON_JOBS_KEY] = new Map();
|
||||
}
|
||||
return globalThis[CRON_JOBS_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a cron job
|
||||
* @param {string} name - Unique name for the job
|
||||
* @param {string} schedule - Cron schedule expression
|
||||
* @param {Function} handler - Handler function to execute
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.timezone - Timezone (default: from env or America/Toronto)
|
||||
* @param {boolean} options.runOnInit - Run immediately on schedule (default: false)
|
||||
* @returns {Object} Cron job instance
|
||||
*
|
||||
* @example
|
||||
* schedule('my-task', '0 9 * * *', async () => {
|
||||
* console.log('Running every day at 9 AM');
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* schedule('reminder', ''\''*\/5 5-17 * * *'\'', async () => {
|
||||
* console.log('Every 5 minutes between 5 AM and 5 PM');
|
||||
* }, { timezone: 'America/New_York' });
|
||||
*/
|
||||
export function schedule(name, cronSchedule, handler, options = {}) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
// Stop existing job with same name
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
console.log(`[Cron] Stopped existing job: ${name}`);
|
||||
}
|
||||
|
||||
const timezone = options.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto';
|
||||
|
||||
const job = cron.schedule(cronSchedule, async () => {
|
||||
console.log(`[Cron: ${name}] Running at:`, new Date().toISOString());
|
||||
|
||||
try {
|
||||
await handler();
|
||||
console.log(`[Cron: ${name}] Completed`);
|
||||
} catch (error) {
|
||||
console.error(`[Cron: ${name}] Error:`, error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone,
|
||||
runOnInit: options.runOnInit || false
|
||||
});
|
||||
|
||||
jobs.set(name, job);
|
||||
console.log(`[Cron] Scheduled job: ${name} (${cronSchedule})`);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a scheduled cron job
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean} True if job was stopped
|
||||
*/
|
||||
export function stop(name) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
jobs.delete(name);
|
||||
console.log(`[Cron] Stopped job: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all cron jobs
|
||||
*/
|
||||
export function stopAll() {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
for (const [name, job] of jobs.entries()) {
|
||||
job.stop();
|
||||
console.log(`[Cron] Stopped job: ${name}`);
|
||||
}
|
||||
|
||||
jobs.clear();
|
||||
console.log('[Cron] All jobs stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Status of all jobs
|
||||
*/
|
||||
export function getStatus() {
|
||||
const jobs = getJobsStorage();
|
||||
const status = {};
|
||||
|
||||
for (const [name] of jobs.entries()) {
|
||||
status[name] = { running: true };
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cron job is running
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRunning(name) {
|
||||
const jobs = getJobsStorage();
|
||||
return jobs.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
* @param {string} expression - Cron expression to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
export function validate(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all scheduled job names
|
||||
* @returns {string[]} Array of job names
|
||||
*/
|
||||
export function getJobs() {
|
||||
const jobs = getJobsStorage();
|
||||
return Array.from(jobs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a job by name
|
||||
* @param {string} name - Job name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function trigger(name) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
if (!jobs.has(name)) {
|
||||
throw new Error(`Cron job '${name}' not found`);
|
||||
}
|
||||
|
||||
console.log(`[Cron] Manual trigger for: ${name}`);
|
||||
// Note: node-cron doesn't expose the handler directly,
|
||||
// so modules should keep their handler function accessible
|
||||
}
|
||||
|
||||
// Re-export the raw cron module for advanced usage
|
||||
export { cron };
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
schedule,
|
||||
stop,
|
||||
stopAll,
|
||||
getStatus,
|
||||
isRunning,
|
||||
validate,
|
||||
getJobs,
|
||||
trigger,
|
||||
cron
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Zen Database CLI
|
||||
* Command-line tool for database management
|
||||
*/
|
||||
|
||||
// Load environment variables from the project's .env file
|
||||
import dotenv from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Load .env from the current working directory (user's project)
|
||||
dotenv.config({ path: resolve(process.cwd(), '.env') });
|
||||
dotenv.config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
// The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
import { initDatabase, dropAuthTables, testConnection, closePool } from './index.js';
|
||||
import readline from 'readline';
|
||||
|
||||
async function runCLI() {
|
||||
const command = process.argv[2];
|
||||
|
||||
if (!command) {
|
||||
console.log(`
|
||||
Zen Database CLI
|
||||
|
||||
Usage:
|
||||
npx zen-db <command>
|
||||
|
||||
Commands:
|
||||
init Initialize database (create all required tables)
|
||||
test Test database connection
|
||||
drop Drop all authentication tables (DANGER!)
|
||||
help Show this help message
|
||||
|
||||
Example:
|
||||
npx zen-db init
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'init':
|
||||
console.log('🔧 Initializing database...\n');
|
||||
const result = await initDatabase();
|
||||
console.log(`\n✅ Success! Created ${result.created.length} tables, skipped ${result.skipped.length} existing tables.`);
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
console.log('🔌 Testing database connection...\n');
|
||||
const isConnected = await testConnection();
|
||||
if (isConnected) {
|
||||
console.log('✅ Database connection successful!');
|
||||
} else {
|
||||
console.log('❌ Database connection failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'drop':
|
||||
console.log('⚠️ WARNING: This will delete all authentication tables!\n');
|
||||
console.log('Type "yes" to confirm or Ctrl+C to cancel...');
|
||||
|
||||
// Simple confirmation (in production, you'd use a proper readline)
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
rl.question('Confirm (yes/no): ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'yes') {
|
||||
await dropAuthTables();
|
||||
console.log('✅ Tables dropped successfully.');
|
||||
} else {
|
||||
console.log('❌ Operation cancelled.');
|
||||
}
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
return; // Don't close the process yet
|
||||
|
||||
case 'help':
|
||||
console.log(`
|
||||
Zen Database CLI
|
||||
|
||||
Commands:
|
||||
init Initialize database (create all required tables)
|
||||
test Test database connection
|
||||
drop Drop all authentication tables (DANGER!)
|
||||
help Show this help message
|
||||
|
||||
Usage:
|
||||
npx zen-db <command>
|
||||
`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`❌ Unknown command: ${command}`);
|
||||
console.log('Run "npx zen-db help" for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Close the database connection pool
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run CLI if called directly
|
||||
import { fileURLToPath } from 'url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
const __filename = realpathSync(fileURLToPath(import.meta.url));
|
||||
const isMainModule = process.argv[1] && realpathSync(process.argv[1]) === __filename;
|
||||
|
||||
if (isMainModule) {
|
||||
runCLI();
|
||||
}
|
||||
|
||||
export { runCLI };
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* CRUD Helper Functions
|
||||
* Provides convenient methods for Create, Read, Update, Delete operations
|
||||
*/
|
||||
|
||||
import { query, queryOne, queryAll } from './db.js';
|
||||
|
||||
/**
|
||||
* Insert a new record into a table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} data - Object with column names as keys and values to insert
|
||||
* @returns {Promise<Object>} Inserted record with all fields
|
||||
*/
|
||||
async function create(tableName, data) {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${tableName} (${columns.join(', ')})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, values);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<Object|null>} Found record or null
|
||||
*/
|
||||
async function findById(tableName, id, idColumn = 'id') {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE ${idColumn} = $1`;
|
||||
return await queryOne(sql, [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @param {Object} options - Query options (limit, offset, orderBy)
|
||||
* @returns {Promise<Array>} Array of matching records
|
||||
*/
|
||||
async function find(tableName, conditions = {}, options = {}) {
|
||||
const { limit, offset, orderBy } = options;
|
||||
|
||||
let sql = `SELECT * FROM ${tableName}`;
|
||||
const values = [];
|
||||
|
||||
// Build WHERE clause
|
||||
if (Object.keys(conditions).length > 0) {
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
if (orderBy) {
|
||||
sql += ` ORDER BY ${orderBy}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (limit) {
|
||||
sql += ` LIMIT ${parseInt(limit)}`;
|
||||
}
|
||||
|
||||
// Add OFFSET
|
||||
if (offset) {
|
||||
sql += ` OFFSET ${parseInt(offset)}`;
|
||||
}
|
||||
|
||||
return await queryAll(sql, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<Object|null>} Found record or null
|
||||
*/
|
||||
async function findOne(tableName, conditions) {
|
||||
const results = await find(tableName, conditions, { limit: 1 });
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {Object} data - Object with column names as keys and new values
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<Object|null>} Updated record or null if not found
|
||||
*/
|
||||
async function updateById(tableName, id, data, idColumn = 'id') {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
|
||||
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||
|
||||
const sql = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${idColumn} = $${values.length + 1}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, [...values, id]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @param {Object} data - Object with column names as keys and new values
|
||||
* @returns {Promise<Array>} Array of updated records
|
||||
*/
|
||||
async function update(tableName, conditions, data) {
|
||||
const dataColumns = Object.keys(data);
|
||||
const dataValues = Object.values(data);
|
||||
|
||||
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||
|
||||
let paramIndex = dataValues.length + 1;
|
||||
const whereConditions = Object.keys(conditions).map((key) => {
|
||||
dataValues.push(conditions[key]);
|
||||
return `${key} = $${paramIndex++}`;
|
||||
});
|
||||
|
||||
const sql = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, dataValues);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<boolean>} True if record was deleted, false otherwise
|
||||
*/
|
||||
async function deleteById(tableName, id, idColumn = 'id') {
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${idColumn} = $1 RETURNING *`;
|
||||
const result = await query(sql, [id]);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<number>} Number of deleted records
|
||||
*/
|
||||
async function deleteWhere(tableName, conditions) {
|
||||
const values = [];
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
|
||||
const result = await query(sql, values);
|
||||
return result.rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records in a table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match (optional)
|
||||
* @returns {Promise<number>} Number of records
|
||||
*/
|
||||
async function count(tableName, conditions = {}) {
|
||||
let sql = `SELECT COUNT(*) as count FROM ${tableName}`;
|
||||
const values = [];
|
||||
|
||||
if (Object.keys(conditions).length > 0) {
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, values);
|
||||
return parseInt(result.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record exists
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<boolean>} True if record exists, false otherwise
|
||||
*/
|
||||
async function exists(tableName, conditions) {
|
||||
const recordCount = await count(tableName, conditions);
|
||||
return recordCount > 0;
|
||||
}
|
||||
|
||||
export {
|
||||
create,
|
||||
findById,
|
||||
find,
|
||||
findOne,
|
||||
updateById,
|
||||
update,
|
||||
deleteById,
|
||||
deleteWhere,
|
||||
count,
|
||||
exists
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Database Connection and Query Utilities
|
||||
* Provides PostgreSQL database connection and query execution functions
|
||||
*/
|
||||
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
|
||||
let pool = null;
|
||||
|
||||
function resolveDatabaseUrl() {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev && process.env.ZEN_DATABASE_URL_DEV) {
|
||||
return process.env.ZEN_DATABASE_URL_DEV;
|
||||
}
|
||||
return process.env.ZEN_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a database connection pool
|
||||
* @returns {Pool} PostgreSQL connection pool
|
||||
*/
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const databaseUrl = resolveDatabaseUrl();
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'ZEN_DATABASE_URL or ZEN_DATABASE_URL_DEV must be defined in environment variables'
|
||||
: 'ZEN_DATABASE_URL is not defined in environment variables'
|
||||
);
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
max: 20, // Maximum number of clients in the pool
|
||||
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
||||
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
|
||||
});
|
||||
|
||||
// Handle pool errors
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SQL query
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Object>} Query result
|
||||
*/
|
||||
async function query(sql, params = []) {
|
||||
const client = getPool();
|
||||
|
||||
try {
|
||||
const result = await client.query(sql, params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return the first row
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Object|null>} First row or null if no results
|
||||
*/
|
||||
async function queryOne(sql, params = []) {
|
||||
const result = await query(sql, params);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return all rows
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Array>} Array of rows
|
||||
*/
|
||||
async function queryAll(sql, params = []) {
|
||||
const result = await query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
* @param {Function} callback - Async function that receives a client and executes queries
|
||||
* @returns {Promise<any>} Result of the callback function
|
||||
*/
|
||||
async function transaction(callback) {
|
||||
const client = await getPool().connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Transaction error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection pool
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
* @returns {Promise<boolean>} True if connection successful
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
await query('SELECT NOW()');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
query,
|
||||
queryOne,
|
||||
queryAll,
|
||||
transaction,
|
||||
getPool,
|
||||
closePool,
|
||||
testConnection
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Zen Database Module
|
||||
* Complete database utilities for PostgreSQL
|
||||
*/
|
||||
|
||||
// Core database functions
|
||||
export {
|
||||
query,
|
||||
queryOne,
|
||||
queryAll,
|
||||
transaction,
|
||||
getPool,
|
||||
closePool,
|
||||
testConnection
|
||||
} from './db.js';
|
||||
|
||||
// CRUD helper functions
|
||||
export {
|
||||
create,
|
||||
findById,
|
||||
find,
|
||||
findOne,
|
||||
updateById,
|
||||
update,
|
||||
deleteById,
|
||||
deleteWhere,
|
||||
count,
|
||||
exists
|
||||
} from './crud.js';
|
||||
|
||||
// Database initialization
|
||||
export {
|
||||
initDatabase,
|
||||
createAuthTables,
|
||||
tableExists,
|
||||
dropAuthTables
|
||||
} from './init.js';
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Database Initialization
|
||||
* Creates required tables if they don't exist
|
||||
*/
|
||||
|
||||
import { query } from './db.js';
|
||||
|
||||
/**
|
||||
* Check if a table exists in the database
|
||||
* @param {string} tableName - Name of the table to check
|
||||
* @returns {Promise<boolean>} True if table exists, false otherwise
|
||||
*/
|
||||
async function tableExists(tableName) {
|
||||
const result = await query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
[tableName]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authentication tables
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createAuthTables() {
|
||||
const tables = [
|
||||
{
|
||||
name: 'zen_auth_users',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_users (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL UNIQUE,
|
||||
email_verified boolean NOT NULL DEFAULT false,
|
||||
image text,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
role text DEFAULT 'user'
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_sessions',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_sessions (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
expires_at timestamptz NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_accounts',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_accounts (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
account_id text NOT NULL,
|
||||
provider_id text NOT NULL,
|
||||
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE,
|
||||
access_token text,
|
||||
refresh_token text,
|
||||
id_token text,
|
||||
access_token_expires_at timestamptz,
|
||||
refresh_token_expires_at timestamptz,
|
||||
scope text,
|
||||
password text,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_verifications',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_verifications (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
identifier text NOT NULL,
|
||||
value text NOT NULL,
|
||||
token text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const table of tables) {
|
||||
const exists = await tableExists(table.name);
|
||||
|
||||
if (!exists) {
|
||||
await query(table.sql);
|
||||
created.push(table.name);
|
||||
console.log(`✓ Created table: ${table.name}`);
|
||||
} else {
|
||||
skipped.push(table.name);
|
||||
console.log(`- Table already exists: ${table.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
skipped,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with all required tables
|
||||
* @returns {Promise<Object>} Result object with created and skipped tables
|
||||
*/
|
||||
async function initDatabase() {
|
||||
console.log('Initializing Zen database...');
|
||||
|
||||
try {
|
||||
const authResult = await createAuthTables();
|
||||
|
||||
// Initialize modules
|
||||
let modulesResult = { created: [], skipped: [] };
|
||||
try {
|
||||
const { initModules } = await import('../../modules/init.js');
|
||||
modulesResult = await initModules();
|
||||
} catch (error) {
|
||||
// Modules might not be available or enabled
|
||||
console.log('\nNo modules to initialize or modules not available.');
|
||||
}
|
||||
|
||||
console.log('\nDatabase initialization completed!');
|
||||
console.log(`Auth tables created: ${authResult.created.length}`);
|
||||
console.log(`Module tables created: ${modulesResult.created.length}`);
|
||||
console.log(`Total tables skipped: ${authResult.skipped.length + modulesResult.skipped.length}`);
|
||||
|
||||
return {
|
||||
created: [...authResult.created, ...modulesResult.created],
|
||||
skipped: [...authResult.skipped, ...modulesResult.skipped],
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all Zen authentication tables (use with caution!)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function dropAuthTables() {
|
||||
const tables = [
|
||||
'zen_auth_verifications',
|
||||
'zen_auth_accounts',
|
||||
'zen_auth_sessions',
|
||||
'zen_auth_users'
|
||||
];
|
||||
|
||||
console.log('WARNING: Dropping all Zen authentication tables...');
|
||||
|
||||
for (const tableName of tables) {
|
||||
const exists = await tableExists(tableName);
|
||||
if (exists) {
|
||||
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
|
||||
console.log(`✓ Dropped table: ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All authentication tables dropped.');
|
||||
}
|
||||
|
||||
export {
|
||||
initDatabase,
|
||||
createAuthTables,
|
||||
tableExists,
|
||||
dropAuthTables
|
||||
};
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Email Utility using Resend
|
||||
* Centralized email sending functionality for the entire package
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
|
||||
/**
|
||||
* Initialize Resend client
|
||||
*/
|
||||
let resendClient = null;
|
||||
|
||||
function getResendClient() {
|
||||
if (!resendClient) {
|
||||
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
||||
}
|
||||
resendClient = new Resend(apiKey);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sender address with name if available
|
||||
* @param {string} email - Email address
|
||||
* @param {string} name - Sender name (optional)
|
||||
* @returns {string} Formatted sender address
|
||||
*/
|
||||
function formatSenderAddress(email, name) {
|
||||
if (name && name.trim()) {
|
||||
return `${name.trim()} <${email}>`;
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.from - Sender email address (optional, defaults to ZEN_EMAIL_FROM_ADDRESS)
|
||||
* @param {string} options.fromName - Sender name (optional, defaults to ZEN_EMAIL_FROM_NAME)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendEmail({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
// Default from address and name
|
||||
const fromAddress = from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const senderName = fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
|
||||
// Format sender with name if available
|
||||
const formattedFrom = formatSenderAddress(fromAddress, senderName);
|
||||
|
||||
const emailData = {
|
||||
from: formattedFrom,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
...(text && { text }),
|
||||
...(replyTo && { reply_to: replyTo }),
|
||||
...(attachments && { attachments }),
|
||||
...(tags && { tags })
|
||||
};
|
||||
|
||||
const response = await resend.emails.send(emailData);
|
||||
|
||||
// Resend returns { data: { id: "..." }, error: null } or { data: null, error: { message: "..." } }
|
||||
if (response.error) {
|
||||
console.error('[ZEN EMAIL] Resend error:', response.error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send email'
|
||||
};
|
||||
}
|
||||
|
||||
const emailId = response.data?.id || response.id;
|
||||
console.log(`[ZEN EMAIL] Email sent to ${to} - ID: ${emailId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN EMAIL] Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authentication-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAuthEmail({ to, subject, html, text, replyTo }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an application-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAppEmail({ to, subject, html, text, replyTo, attachments, tags }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo,
|
||||
attachments,
|
||||
tags
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of emails
|
||||
* @param {Array<Object>} emails - Array of email objects
|
||||
* @returns {Promise<Array<Object>>} Array of Resend responses
|
||||
*/
|
||||
async function sendBatchEmails(emails) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
const emailsData = emails.map(email => {
|
||||
const fromAddress = email.from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const fromName = email.fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
const formattedFrom = formatSenderAddress(fromAddress, fromName);
|
||||
|
||||
return {
|
||||
from: formattedFrom,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
...(email.text && { text: email.text }),
|
||||
...(email.replyTo && { reply_to: email.replyTo }),
|
||||
...(email.attachments && { attachments: email.attachments }),
|
||||
...(email.tags && { tags: email.tags })
|
||||
};
|
||||
});
|
||||
|
||||
const response = await resend.batch.send(emailsData);
|
||||
|
||||
// Handle Resend error response
|
||||
if (response.error) {
|
||||
console.error('[ZEN EMAIL] Resend batch error:', response.error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send batch emails'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[ZEN EMAIL] Batch of ${emails.length} emails sent`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN EMAIL] Error sending batch emails:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
sendEmail,
|
||||
sendAuthEmail,
|
||||
sendAppEmail,
|
||||
sendBatchEmails
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Base Email Layout Component
|
||||
* Provides consistent structure for all ZEN emails
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Img,
|
||||
Tailwind,
|
||||
Hr,
|
||||
Link,
|
||||
} from "@react-email/components";
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
title,
|
||||
children,
|
||||
companyName,
|
||||
logoURL,
|
||||
supportSection = false,
|
||||
supportEmail = 'support@zenya.test'
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
|
||||
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Html lang="fr">
|
||||
<Head />
|
||||
{preview && <Preview>{preview}</Preview>}
|
||||
<Tailwind>
|
||||
<Body className="bg-white font-sans">
|
||||
<Container className="mx-auto px-[15px] py-[20px] max-w-[525px]">
|
||||
|
||||
<Section className="mb-[40px]">
|
||||
{logoSrc ? (
|
||||
logoHref ? (
|
||||
<Link href={logoHref}>
|
||||
<Img src={logoSrc} alt={appName} className="w-auto max-h-[64px]" />
|
||||
</Link>
|
||||
) : (
|
||||
<Img src={logoSrc} alt={appName} className="w-auto max-h-[64px]" />
|
||||
)
|
||||
) : (
|
||||
<Heading className="text-[16px] font-semibold text-neutral-900 m-0 tracking-tight">
|
||||
{appName}
|
||||
</Heading>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{title && (
|
||||
<Heading className="text-[22px] font-semibold text-neutral-900 mt-0 mb-[8px] tracking-tight">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
<Hr className="border-neutral-100 mt-[48px] mb-[20px]" />
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
© {currentYear} — {appName}. Tous droits réservés.
|
||||
{supportSection && (
|
||||
<>
|
||||
{' · '}
|
||||
<Link href={`mailto:${supportEmail}`} className="text-neutral-400 underline">
|
||||
{supportEmail}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Password Changed Confirmation Email Template
|
||||
*/
|
||||
|
||||
import { Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordChangedEmail = ({
|
||||
email,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Votre mot de passe a été modifié – ${appName}`}
|
||||
title="Mot de passe modifié"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{appName}</span> a bien été modifié.
|
||||
</Text>
|
||||
|
||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||
Compte
|
||||
</Text>
|
||||
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||
{email}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Password Reset Email Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordResetEmail = ({
|
||||
email,
|
||||
resetUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Réinitialisez votre mot de passe pour ${appName}`}
|
||||
title="Réinitialisation du mot de passe"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={resetUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Réinitialiser le mot de passe
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre mot de passe ne sera pas modifié.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={resetUrl} className="text-neutral-400 underline break-all">
|
||||
{resetUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Email Verification Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const VerificationEmail = ({
|
||||
email,
|
||||
verificationUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Confirmez votre adresse courriel pour ${appName}`}
|
||||
title="Confirmez votre adresse courriel"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Confirmer mon courriel
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={verificationUrl} className="text-neutral-400 underline break-all">
|
||||
{verificationUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Email Templates
|
||||
* Export all email templates and render functions
|
||||
*/
|
||||
|
||||
import { render } from '@react-email/components';
|
||||
import { VerificationEmail } from './VerificationEmail.jsx';
|
||||
import { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
import { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
|
||||
// Export JSX components
|
||||
export { VerificationEmail } from './VerificationEmail.jsx';
|
||||
export { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
export { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
export { BaseLayout } from './BaseLayout.jsx';
|
||||
|
||||
/**
|
||||
* Render verification email to HTML
|
||||
* @param {string} verificationUrl - The verification URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderVerificationEmail(verificationUrl, email, companyName) {
|
||||
return await render(
|
||||
<VerificationEmail
|
||||
email={email}
|
||||
verificationUrl={verificationUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password reset email to HTML
|
||||
* @param {string} resetUrl - The password reset URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordResetEmail(resetUrl, email, companyName) {
|
||||
return await render(
|
||||
<PasswordResetEmail
|
||||
email={email}
|
||||
resetUrl={resetUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password changed email to HTML
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordChangedEmail(email, companyName) {
|
||||
return await render(
|
||||
<PasswordChangedEmail
|
||||
email={email}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy exports for backward compatibility
|
||||
export const getVerificationEmailTemplate = renderVerificationEmail;
|
||||
export const getPasswordResetTemplate = renderPasswordResetEmail;
|
||||
export const getPasswordChangedTemplate = renderPasswordChangedEmail;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Client-Safe Module Registry Access
|
||||
*
|
||||
* This file ONLY exports functions that are safe to use in client components.
|
||||
* It does NOT export discovery, loader, or initialization functions that
|
||||
* might import server-only modules like database code.
|
||||
*
|
||||
* NOTE: Most registry functions return empty results on the client because
|
||||
* the registry is populated on the server during discovery. For client-side
|
||||
* module page loading, use the loaders from modules.pages.js instead.
|
||||
*/
|
||||
|
||||
// Only export registry getter functions (no discovery/loader functions)
|
||||
export {
|
||||
getModule,
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
isModuleRegistered,
|
||||
isModuleEnabled,
|
||||
getAllApiRoutes,
|
||||
getAllAdminNavigation,
|
||||
getAdminPage,
|
||||
getAllCronJobs,
|
||||
getAllPublicRoutes,
|
||||
getAllDatabaseSchemas,
|
||||
getModuleMetadata,
|
||||
getAllModuleMetadata,
|
||||
} from './registry.js';
|
||||
|
||||
// NOTE: getModulePublicPages is NOT exported here because it relies on the
|
||||
// server-side registry which is empty on the client. Use getModulePublicPageLoader()
|
||||
// from '@hykocx/zen/modules/pages' instead for client-side public page loading.
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Module Discovery System
|
||||
* Auto-discovers and registers modules from the modules directory
|
||||
*/
|
||||
|
||||
import { registerModule, clearRegistry } from './registry.js';
|
||||
import { getAvailableModules } from '../../modules/modules.registry.js';
|
||||
|
||||
/**
|
||||
* Check if a module is enabled via environment variable
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleEnabledInEnv(moduleName) {
|
||||
const envVar = `ZEN_MODULE_${moduleName.toUpperCase()}`;
|
||||
return process.env[envVar] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and register all modules
|
||||
* @param {Object} options - Discovery options
|
||||
* @param {boolean} options.force - Force re-discovery
|
||||
* @returns {Promise<Object>} Discovery result
|
||||
*/
|
||||
export async function discoverModules(options = {}) {
|
||||
const { force = false } = options;
|
||||
|
||||
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
|
||||
|
||||
if (globalThis[DISCOVERY_KEY] && !force) {
|
||||
console.log('[Module Discovery] Already discovered, skipping...');
|
||||
return { alreadyDiscovered: true };
|
||||
}
|
||||
|
||||
if (force) {
|
||||
clearRegistry();
|
||||
}
|
||||
|
||||
console.log('[Module Discovery] Starting module discovery...');
|
||||
|
||||
const discovered = [];
|
||||
const enabled = [];
|
||||
const skipped = [];
|
||||
const errors = [];
|
||||
|
||||
const knownModules = getAvailableModules();
|
||||
|
||||
for (const moduleName of knownModules) {
|
||||
try {
|
||||
const isEnabled = isModuleEnabledInEnv(moduleName);
|
||||
|
||||
if (!isEnabled) {
|
||||
skipped.push(moduleName);
|
||||
console.log(`[Module Discovery] Skipped ${moduleName} (not enabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load module configuration
|
||||
const moduleConfig = await loadModuleConfig(moduleName);
|
||||
|
||||
if (moduleConfig) {
|
||||
// Load additional components (db, cron, api)
|
||||
const components = await loadModuleComponents(moduleName);
|
||||
|
||||
// Register the module
|
||||
registerModule(moduleName, {
|
||||
...moduleConfig,
|
||||
...components,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
discovered.push(moduleName);
|
||||
enabled.push(moduleName);
|
||||
console.log(`[Module Discovery] Registered ${moduleName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({ module: moduleName, error: error.message });
|
||||
console.error(`[Module Discovery] Error loading ${moduleName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis[DISCOVERY_KEY] = true;
|
||||
|
||||
console.log(`[Module Discovery] Complete. Enabled: ${enabled.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`);
|
||||
|
||||
return { discovered, enabled, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module configuration from module.config.js
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Promise<Object|null>} Module configuration
|
||||
*/
|
||||
async function loadModuleConfig(moduleName) {
|
||||
try {
|
||||
const config = await import(`../../modules/${moduleName}/module.config.js`);
|
||||
const moduleConfig = config.default || config;
|
||||
|
||||
// Build admin config with navigation and pages
|
||||
let adminConfig = undefined;
|
||||
if (moduleConfig.navigation || moduleConfig.adminPages) {
|
||||
adminConfig = {};
|
||||
if (moduleConfig.navigation) {
|
||||
adminConfig.navigation = moduleConfig.navigation;
|
||||
}
|
||||
// Extract admin page paths (keys only, not the lazy components)
|
||||
// This allows getAdminPage() to know which paths belong to this module
|
||||
if (moduleConfig.adminPages) {
|
||||
adminConfig.pages = {};
|
||||
for (const path of Object.keys(moduleConfig.adminPages)) {
|
||||
// Store true as a marker that this path exists
|
||||
// The actual component is loaded client-side via modules.pages.js
|
||||
adminConfig.pages[path] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract server-side relevant data
|
||||
return {
|
||||
name: moduleConfig.name || moduleName,
|
||||
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
|
||||
version: moduleConfig.version || '1.0.0',
|
||||
description: moduleConfig.description || `${moduleName} module`,
|
||||
dependencies: moduleConfig.dependencies || [],
|
||||
envVars: moduleConfig.envVars || [],
|
||||
// Admin configuration (navigation + page paths)
|
||||
admin: adminConfig,
|
||||
// Public routes metadata (not components)
|
||||
public: moduleConfig.publicRoutes ? {
|
||||
routes: moduleConfig.publicRoutes
|
||||
} : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
|
||||
return {
|
||||
name: moduleName,
|
||||
displayName: moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
|
||||
version: '1.0.0',
|
||||
description: `${moduleName} module`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load additional module components (db, cron, api)
|
||||
* Note: Metadata is loaded from modules.metadata.js (static registry)
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Promise<Object>} Module components
|
||||
*/
|
||||
async function loadModuleComponents(moduleName) {
|
||||
const components = {};
|
||||
|
||||
// Load API routes
|
||||
try {
|
||||
const api = await import(`../../modules/${moduleName}/api.js`);
|
||||
components.api = api.default || api;
|
||||
} catch (error) {
|
||||
// API is optional
|
||||
}
|
||||
|
||||
// Load cron configuration
|
||||
try {
|
||||
const cron = await import(`../../modules/${moduleName}/cron.config.js`);
|
||||
components.cron = cron.default || cron;
|
||||
} catch (error) {
|
||||
// Cron is optional
|
||||
}
|
||||
|
||||
// Load database configuration
|
||||
try {
|
||||
const db = await import(`../../modules/${moduleName}/db.js`);
|
||||
if (db.createTables) {
|
||||
components.db = {
|
||||
init: db.createTables,
|
||||
drop: db.dropTables
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// DB is optional
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module discovery (useful for testing)
|
||||
*/
|
||||
export function resetDiscovery() {
|
||||
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
|
||||
globalThis[DISCOVERY_KEY] = false;
|
||||
clearRegistry();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Module System Entry Point
|
||||
* Exports all module-related functionality
|
||||
*/
|
||||
|
||||
// Discovery
|
||||
export {
|
||||
discoverModules,
|
||||
isModuleEnabledInEnv,
|
||||
resetDiscovery
|
||||
} from './discovery.js';
|
||||
|
||||
// Registry (server-side only - these functions rely on the registry populated during discovery)
|
||||
export {
|
||||
registerModule,
|
||||
getModule,
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
isModuleRegistered,
|
||||
isModuleEnabled,
|
||||
clearRegistry,
|
||||
getAllApiRoutes,
|
||||
getAllAdminNavigation,
|
||||
getAdminPage,
|
||||
getAllCronJobs,
|
||||
getAllPublicRoutes,
|
||||
getAllDatabaseSchemas,
|
||||
getModuleMetadata,
|
||||
getAllModuleMetadata,
|
||||
getModulePublicPages // returns route metadata only, use modules.pages.js for components
|
||||
} from './registry.js';
|
||||
|
||||
// Loader
|
||||
export {
|
||||
initializeModules,
|
||||
initializeModuleDatabases,
|
||||
startModuleCronJobs,
|
||||
stopModuleCronJobs,
|
||||
getCronJobStatus,
|
||||
resetModuleLoader,
|
||||
getModuleStatus
|
||||
} from './loader.js';
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Module Loader
|
||||
* Handles loading and initializing modules
|
||||
*/
|
||||
|
||||
import { discoverModules, resetDiscovery } from './discovery.js';
|
||||
import {
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
getAllCronJobs,
|
||||
getAllDatabaseSchemas,
|
||||
isModuleEnabled
|
||||
} from './registry.js';
|
||||
|
||||
// Use globalThis to track initialization state
|
||||
const INIT_KEY = Symbol.for('__ZEN_MODULES_INITIALIZED__');
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_MODULE_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize all modules
|
||||
* Discovers modules, initializes databases, and starts cron jobs
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {boolean} options.skipCron - Skip starting cron jobs
|
||||
* @param {boolean} options.skipDb - Skip database initialization
|
||||
* @param {boolean} options.force - Force re-initialization
|
||||
* @returns {Promise<Object>} Initialization result
|
||||
*/
|
||||
export async function initializeModules(options = {}) {
|
||||
const { skipCron = false, skipDb = false, force = false } = options;
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (globalThis[INIT_KEY] && !force) {
|
||||
console.log('[Module Loader] Already initialized, skipping...');
|
||||
return { alreadyInitialized: true };
|
||||
}
|
||||
|
||||
console.log('[Module Loader] Starting module initialization...');
|
||||
|
||||
const result = {
|
||||
discovery: null,
|
||||
database: { created: [], skipped: [], errors: [] },
|
||||
cron: { started: [], errors: [] }
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Discover modules
|
||||
result.discovery = await discoverModules({ force });
|
||||
|
||||
// Step 2: Initialize databases
|
||||
if (!skipDb) {
|
||||
result.database = await initializeModuleDatabases();
|
||||
}
|
||||
|
||||
// Step 3: Start cron jobs
|
||||
if (!skipCron) {
|
||||
result.cron = await startModuleCronJobs();
|
||||
}
|
||||
|
||||
globalThis[INIT_KEY] = true;
|
||||
console.log('[Module Loader] Module initialization complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Module Loader] Initialization failed:', error);
|
||||
result.error = error.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize databases for all enabled modules
|
||||
* @returns {Promise<Object>} Database initialization result
|
||||
*/
|
||||
export async function initializeModuleDatabases() {
|
||||
console.log('[Module Loader] Initializing module databases...');
|
||||
|
||||
const schemas = getAllDatabaseSchemas();
|
||||
const result = {
|
||||
created: [],
|
||||
skipped: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const schema of schemas) {
|
||||
try {
|
||||
if (schema.init && typeof schema.init === 'function') {
|
||||
const initResult = await schema.init();
|
||||
|
||||
if (initResult?.created) {
|
||||
result.created.push(...initResult.created);
|
||||
}
|
||||
if (initResult?.skipped) {
|
||||
result.skipped.push(...initResult.skipped);
|
||||
}
|
||||
|
||||
console.log(`[Module Loader] Database initialized for ${schema.module}`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
module: schema.module,
|
||||
error: error.message
|
||||
});
|
||||
console.error(`[Module Loader] Database init error for ${schema.module}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cron jobs for all enabled modules
|
||||
* @returns {Promise<Object>} Cron job start result
|
||||
*/
|
||||
export async function startModuleCronJobs() {
|
||||
console.log('[Module Loader] Starting module cron jobs...');
|
||||
|
||||
// Stop existing cron jobs first
|
||||
stopModuleCronJobs();
|
||||
|
||||
const jobs = getAllCronJobs();
|
||||
const result = {
|
||||
started: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Initialize cron jobs storage
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
globalThis[CRON_JOBS_KEY] = new Map();
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.handler && typeof job.handler === 'function') {
|
||||
// Dynamic import of node-cron
|
||||
const cron = (await import('node-cron')).default;
|
||||
|
||||
const cronJob = cron.schedule(job.schedule, async () => {
|
||||
console.log(`[Cron: ${job.name}] Running at:`, new Date().toISOString());
|
||||
try {
|
||||
await job.handler();
|
||||
console.log(`[Cron: ${job.name}] Completed`);
|
||||
} catch (error) {
|
||||
console.error(`[Cron: ${job.name}] Error:`, error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: job.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto'
|
||||
});
|
||||
|
||||
globalThis[CRON_JOBS_KEY].set(job.name, cronJob);
|
||||
result.started.push(job.name);
|
||||
|
||||
console.log(`[Module Loader] Started cron job: ${job.name} (${job.schedule})`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
job: job.name,
|
||||
module: job.module,
|
||||
error: error.message
|
||||
});
|
||||
console.error(`[Module Loader] Cron job error for ${job.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all module cron jobs
|
||||
*/
|
||||
export function stopModuleCronJobs() {
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
try {
|
||||
job.stop();
|
||||
console.log(`[Module Loader] Stopped cron job: ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Module Loader] Error stopping cron job ${name}:`, error);
|
||||
}
|
||||
}
|
||||
globalThis[CRON_JOBS_KEY].clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Cron job status
|
||||
*/
|
||||
export function getCronJobStatus() {
|
||||
const status = {};
|
||||
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
status[name] = {
|
||||
running: true // node-cron doesn't expose a running state easily
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module loader (useful for testing)
|
||||
*/
|
||||
export function resetModuleLoader() {
|
||||
stopModuleCronJobs();
|
||||
resetDiscovery();
|
||||
globalThis[INIT_KEY] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module status
|
||||
* @returns {Object} Status of all modules
|
||||
*/
|
||||
export function getModuleStatus() {
|
||||
const modules = getAllModules();
|
||||
const enabled = getEnabledModules();
|
||||
const cronStatus = getCronJobStatus();
|
||||
|
||||
return {
|
||||
totalModules: modules.size,
|
||||
enabledModules: enabled.length,
|
||||
modules: Array.from(modules.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
enabled: data.enabled,
|
||||
displayName: data.displayName,
|
||||
version: data.version,
|
||||
hasApi: !!data.api,
|
||||
hasAdmin: !!data.admin,
|
||||
hasCron: !!data.cron,
|
||||
hasDb: !!data.db,
|
||||
hasPublic: !!data.public
|
||||
})),
|
||||
cronJobs: cronStatus
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export useful functions from registry
|
||||
export {
|
||||
isModuleEnabled,
|
||||
getAllModules,
|
||||
getEnabledModules
|
||||
} from './registry.js';
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Module Registry
|
||||
* Stores and manages all discovered modules
|
||||
*/
|
||||
|
||||
// Use globalThis to persist registry across module reloads
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_MODULE_REGISTRY__');
|
||||
|
||||
/**
|
||||
* Initialize or get the module registry
|
||||
* @returns {Map} Module registry map
|
||||
*/
|
||||
function getRegistry() {
|
||||
if (!globalThis[REGISTRY_KEY]) {
|
||||
globalThis[REGISTRY_KEY] = new Map();
|
||||
}
|
||||
return globalThis[REGISTRY_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module in the registry
|
||||
* @param {string} name - Module name
|
||||
* @param {Object} moduleData - Module configuration and components
|
||||
*/
|
||||
export function registerModule(name, moduleData) {
|
||||
const registry = getRegistry();
|
||||
registry.set(name, {
|
||||
...moduleData,
|
||||
registeredAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered module by name
|
||||
* @param {string} name - Module name
|
||||
* @returns {Object|null} Module data or null
|
||||
*/
|
||||
export function getModule(name) {
|
||||
const registry = getRegistry();
|
||||
return registry.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered modules
|
||||
* @returns {Map} All registered modules
|
||||
*/
|
||||
export function getAllModules() {
|
||||
return getRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled modules
|
||||
* @returns {Array} Array of enabled module data
|
||||
*/
|
||||
export function getEnabledModules() {
|
||||
const registry = getRegistry();
|
||||
const enabled = [];
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled) {
|
||||
enabled.push({ name, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is registered
|
||||
* @param {string} name - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleRegistered(name) {
|
||||
const registry = getRegistry();
|
||||
return registry.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is enabled
|
||||
* @param {string} name - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleEnabled(name) {
|
||||
const module = getModule(name);
|
||||
return module?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the module registry (useful for testing)
|
||||
*/
|
||||
export function clearRegistry() {
|
||||
const registry = getRegistry();
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API routes from enabled modules
|
||||
* @returns {Array} Array of route definitions
|
||||
*/
|
||||
export function getAllApiRoutes() {
|
||||
const routes = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.api?.routes) {
|
||||
routes.push(...data.api.routes.map(route => ({
|
||||
...route,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin navigation sections from enabled modules
|
||||
* @param {string} pathname - Current pathname for active state
|
||||
* @returns {Array} Array of navigation sections
|
||||
*/
|
||||
export function getAllAdminNavigation(pathname) {
|
||||
const sections = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.admin?.navigation) {
|
||||
const nav = data.admin.navigation;
|
||||
|
||||
// Handle function or object navigation
|
||||
const section = typeof nav === 'function' ? nav(pathname) : nav;
|
||||
|
||||
if (section) {
|
||||
// Support array of sections (e.g. one per post type)
|
||||
const sectionList = Array.isArray(section) ? section : [section];
|
||||
for (const s of sectionList) {
|
||||
if (s.items) {
|
||||
s.items = s.items.map(item => ({
|
||||
...item,
|
||||
current: pathname.startsWith(item.href)
|
||||
}));
|
||||
}
|
||||
sections.push({ ...s, module: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin page info for a given path
|
||||
*
|
||||
* Returns module info if the path is registered as an admin page.
|
||||
* The actual component is loaded client-side via modules.pages.js
|
||||
*
|
||||
* @param {string} path - Page path (e.g., '/admin/invoice/invoices')
|
||||
* @returns {Object|null} Object with { module, path } or null
|
||||
*/
|
||||
export function getAdminPage(path) {
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.admin?.pages) {
|
||||
if (data.admin.pages[path]) {
|
||||
return { module: name, path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cron jobs from enabled modules
|
||||
* @returns {Array} Array of cron job definitions
|
||||
*/
|
||||
export function getAllCronJobs() {
|
||||
const jobs = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.cron?.jobs) {
|
||||
jobs.push(...data.cron.jobs.map(job => ({
|
||||
...job,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public routes from enabled modules
|
||||
* @returns {Array} Array of public route definitions
|
||||
*/
|
||||
export function getAllPublicRoutes() {
|
||||
const routes = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.public?.routes) {
|
||||
routes.push(...data.public.routes.map(route => ({
|
||||
...route,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database schemas from all enabled modules
|
||||
* @returns {Array} Array of database schema definitions
|
||||
*/
|
||||
export function getAllDatabaseSchemas() {
|
||||
const schemas = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.db) {
|
||||
schemas.push({
|
||||
module: name,
|
||||
...data.db
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata generator function from a module
|
||||
* @param {string} moduleName - Module name (e.g., 'invoice')
|
||||
* @param {string} type - Metadata type (e.g., 'payment', 'pdf', 'receipt')
|
||||
* @returns {Function|null} Metadata generator function or null if not found
|
||||
*/
|
||||
export function getModuleMetadata(moduleName, type) {
|
||||
const module = getModule(moduleName);
|
||||
|
||||
if (module?.enabled && module?.metadata) {
|
||||
// If type is specified, return the specific generator
|
||||
if (type && module.metadata[type]) {
|
||||
return module.metadata[type];
|
||||
}
|
||||
// If no type, return the default (first one or 'payment')
|
||||
return module.metadata.payment || module.metadata[Object.keys(module.metadata)[0]] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metadata configurations from enabled modules
|
||||
* @returns {Object} Object mapping module names to their metadata configs
|
||||
*/
|
||||
export function getAllModuleMetadata() {
|
||||
const metadata = {};
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.metadata) {
|
||||
metadata[name] = data.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public routes configuration from a module
|
||||
*
|
||||
* NOTE: This function only returns route metadata, not components.
|
||||
* For loading public page components, use getModulePublicPageLoader() from modules.pages.js
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object|null} Public routes config or null
|
||||
*/
|
||||
export function getModulePublicPages(moduleName) {
|
||||
const module = getModule(moduleName);
|
||||
|
||||
if (module?.enabled && module?.public) {
|
||||
return module.public;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Payments Module Entry Point
|
||||
* Re-exports all payment utilities
|
||||
*/
|
||||
|
||||
export * from './stripe.js';
|
||||
export { default as stripe } from './stripe.js';
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Stripe Payment Utilities
|
||||
* Generic Stripe integration for payment processing
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { createCheckoutSession, isEnabled } from '@hykocx/zen/stripe';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get Stripe instance
|
||||
* @returns {Promise<Object>} Stripe instance
|
||||
*/
|
||||
export async function getStripe() {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!secretKey) {
|
||||
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
|
||||
}
|
||||
|
||||
const Stripe = (await import('stripe')).default;
|
||||
return new Stripe(secretKey, {
|
||||
apiVersion: '2023-10-16',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Stripe is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEnabled() {
|
||||
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe publishable key (for client-side)
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPublishableKey() {
|
||||
return process.env.STRIPE_PUBLISHABLE_KEY || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkout session
|
||||
* @param {Object} options - Checkout options
|
||||
* @param {Array} options.lineItems - Line items for checkout
|
||||
* @param {string} options.successUrl - Success redirect URL
|
||||
* @param {string} options.cancelUrl - Cancel redirect URL
|
||||
* @param {string} options.customerEmail - Customer email
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @param {string} options.mode - Payment mode (default: 'payment')
|
||||
* @returns {Promise<Object>} Stripe session object
|
||||
*
|
||||
* @example
|
||||
* const session = await createCheckoutSession({
|
||||
* lineItems: [{
|
||||
* price_data: {
|
||||
* currency: 'usd',
|
||||
* product_data: { name: 'Product' },
|
||||
* unit_amount: 1000,
|
||||
* },
|
||||
* quantity: 1,
|
||||
* }],
|
||||
* successUrl: 'https://example.com/success',
|
||||
* cancelUrl: 'https://example.com/cancel',
|
||||
* });
|
||||
*/
|
||||
export async function createCheckoutSession(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const {
|
||||
lineItems,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
customerEmail,
|
||||
metadata = {},
|
||||
mode = 'payment',
|
||||
paymentMethodTypes = ['card'],
|
||||
clientReferenceId,
|
||||
} = options;
|
||||
|
||||
const sessionConfig = {
|
||||
payment_method_types: paymentMethodTypes,
|
||||
line_items: lineItems,
|
||||
mode,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (customerEmail) {
|
||||
sessionConfig.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
if (clientReferenceId) {
|
||||
sessionConfig.client_reference_id = clientReferenceId;
|
||||
}
|
||||
|
||||
return await stripe.checkout.sessions.create(sessionConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment intent
|
||||
* @param {Object} options - Payment options
|
||||
* @param {number} options.amount - Amount in cents
|
||||
* @param {string} options.currency - Currency code
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @returns {Promise<Object>} Stripe payment intent
|
||||
*/
|
||||
export async function createPaymentIntent(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const {
|
||||
amount,
|
||||
currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
|
||||
metadata = {},
|
||||
automaticPaymentMethods = { enabled: true },
|
||||
} = options;
|
||||
|
||||
return await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
metadata,
|
||||
automatic_payment_methods: automaticPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a checkout session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Promise<Object>} Stripe session
|
||||
*/
|
||||
export async function getCheckoutSession(sessionId) {
|
||||
const stripe = await getStripe();
|
||||
return await stripe.checkout.sessions.retrieve(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a payment intent
|
||||
* @param {string} paymentIntentId - Payment intent ID
|
||||
* @returns {Promise<Object>} Stripe payment intent
|
||||
*/
|
||||
export async function getPaymentIntent(paymentIntentId) {
|
||||
const stripe = await getStripe();
|
||||
return await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature
|
||||
* @param {string} payload - Raw request body
|
||||
* @param {string} signature - Stripe-Signature header
|
||||
* @param {string} secret - Webhook secret (optional, uses env if not provided)
|
||||
* @returns {Promise<Object>} Verified event
|
||||
*/
|
||||
export async function verifyWebhookSignature(payload, signature, secret = null) {
|
||||
const stripe = await getStripe();
|
||||
const webhookSecret = secret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!webhookSecret) {
|
||||
throw new Error('Stripe webhook secret is not configured');
|
||||
}
|
||||
|
||||
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer
|
||||
* @param {Object} options - Customer options
|
||||
* @param {string} options.email - Customer email
|
||||
* @param {string} options.name - Customer name
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @returns {Promise<Object>} Stripe customer
|
||||
*/
|
||||
export async function createCustomer(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const { email, name, metadata = {} } = options;
|
||||
|
||||
return await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a customer by email
|
||||
* @param {string} email - Customer email
|
||||
* @param {Object} defaultData - Default data if creating new customer
|
||||
* @returns {Promise<Object>} Stripe customer
|
||||
*/
|
||||
export async function getOrCreateCustomer(email, defaultData = {}) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
// Search for existing customer
|
||||
const existing = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existing.data.length > 0) {
|
||||
return existing.data[0];
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
return await stripe.customers.create({
|
||||
email,
|
||||
...defaultData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List customer's payment methods
|
||||
* @param {string} customerId - Customer ID
|
||||
* @param {string} type - Payment method type (default: 'card')
|
||||
* @returns {Promise<Array>} List of payment methods
|
||||
*/
|
||||
export async function listPaymentMethods(customerId, type = 'card') {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
type,
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refund
|
||||
* @param {Object} options - Refund options
|
||||
* @param {string} options.paymentIntentId - Payment intent to refund
|
||||
* @param {number} options.amount - Amount to refund in cents (optional, full refund if not specified)
|
||||
* @param {string} options.reason - Reason for refund
|
||||
* @returns {Promise<Object>} Stripe refund
|
||||
*/
|
||||
export async function createRefund(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const { paymentIntentId, amount, reason } = options;
|
||||
|
||||
const refundConfig = {
|
||||
payment_intent: paymentIntentId,
|
||||
};
|
||||
|
||||
if (amount) {
|
||||
refundConfig.amount = amount;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
refundConfig.reason = reason;
|
||||
}
|
||||
|
||||
return await stripe.refunds.create(refundConfig);
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
getStripe,
|
||||
isEnabled,
|
||||
getPublishableKey,
|
||||
createCheckoutSession,
|
||||
createPaymentIntent,
|
||||
getCheckoutSession,
|
||||
getPaymentIntent,
|
||||
verifyWebhookSignature,
|
||||
createCustomer,
|
||||
getOrCreateCustomer,
|
||||
listPaymentMethods,
|
||||
createRefund,
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* PDF Generation Utilities
|
||||
* Wrapper around @react-pdf/renderer for PDF generation
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { renderToBuffer } from '@hykocx/zen/pdf';
|
||||
*/
|
||||
|
||||
import { renderToBuffer as reactPdfRenderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Render a React PDF document to a buffer
|
||||
* @param {React.Element} document - React PDF document element
|
||||
* @returns {Promise<Buffer>} PDF buffer
|
||||
*
|
||||
* @example
|
||||
* import { Document, Page, Text } from '@react-pdf/renderer';
|
||||
*
|
||||
* const MyDoc = () => (
|
||||
* <Document>
|
||||
* <Page>
|
||||
* <Text>Hello World</Text>
|
||||
* </Page>
|
||||
* </Document>
|
||||
* );
|
||||
*
|
||||
* const buffer = await renderToBuffer(<MyDoc />);
|
||||
*/
|
||||
export async function renderToBuffer(document) {
|
||||
return await reactPdfRenderToBuffer(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a React element for PDF rendering
|
||||
* @param {Function} Component - React component
|
||||
* @param {Object} props - Component props
|
||||
* @returns {React.Element}
|
||||
*/
|
||||
export function createElement(Component, props) {
|
||||
return React.createElement(Component, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a suggested filename for a PDF
|
||||
* @param {string} prefix - Filename prefix
|
||||
* @param {string|number} identifier - Unique identifier
|
||||
* @param {Date} date - Date for the filename (default: today)
|
||||
* @returns {string} Suggested filename
|
||||
*
|
||||
* @example
|
||||
* getFilename('invoice', '12345'); // 'invoice-12345-2024-01-15.pdf'
|
||||
*/
|
||||
export function getFilename(prefix, identifier, date = new Date()) {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return `${prefix}-${identifier}-${dateStr}.pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert centimeters to points (for PDF dimensions)
|
||||
* @param {number} cm - Centimeters
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function cmToPoints(cm) {
|
||||
return cm * 28.3465;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert inches to points (for PDF dimensions)
|
||||
* @param {number} inches - Inches
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function inchesToPoints(inches) {
|
||||
return inches * 72;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert millimeters to points (for PDF dimensions)
|
||||
* @param {number} mm - Millimeters
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function mmToPoints(mm) {
|
||||
return mm * 2.83465;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common page sizes in points
|
||||
*/
|
||||
export const PAGE_SIZES = {
|
||||
A4: { width: 595.28, height: 841.89 },
|
||||
LETTER: { width: 612, height: 792 },
|
||||
LEGAL: { width: 612, height: 1008 },
|
||||
A3: { width: 841.89, height: 1190.55 },
|
||||
A5: { width: 419.53, height: 595.28 },
|
||||
};
|
||||
|
||||
// Re-export react-pdf components for convenience
|
||||
export {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Link,
|
||||
StyleSheet,
|
||||
Font,
|
||||
PDFViewer,
|
||||
BlobProvider,
|
||||
PDFDownloadLink,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// Default export
|
||||
export default {
|
||||
renderToBuffer,
|
||||
createElement,
|
||||
getFilename,
|
||||
cmToPoints,
|
||||
inchesToPoints,
|
||||
mmToPoints,
|
||||
PAGE_SIZES,
|
||||
};
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Zen Storage Module - Cloudflare R2
|
||||
* Provides file upload, download, deletion, and management functionality
|
||||
* Uses native fetch + crypto (AWS Signature V4) — no external dependencies
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from 'crypto';
|
||||
|
||||
// ─── AWS Signature V4 ────────────────────────────────────────────────────────
|
||||
|
||||
function sha256hex(data) {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function hmac(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest();
|
||||
}
|
||||
|
||||
function hmacHex(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest('hex');
|
||||
}
|
||||
|
||||
function amzDate(date) {
|
||||
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
}
|
||||
|
||||
function dateStamp(date) {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
|
||||
*/
|
||||
function encodeS3(str) {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URI path, encoding each segment individually (preserving slashes)
|
||||
*/
|
||||
function encodePath(path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map(segment => (segment ? encodeS3(segment) : ''))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function signingKey(secret, ds, region, service) {
|
||||
const kDate = hmac('AWS4' + secret, ds);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an S3 request using AWS Signature V4.
|
||||
* Returns the full URL and the headers object to pass to fetch.
|
||||
*/
|
||||
function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
|
||||
const { accessKeyId, secretAccessKey } = config;
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
|
||||
|
||||
const headers = {
|
||||
host,
|
||||
'x-amz-date': ts,
|
||||
'x-amz-content-sha256': bodyHash,
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
const sortedHeaderKeys = Object.keys(headers).sort();
|
||||
const canonicalHeaders = sortedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const signedHeaders = sortedHeaderKeys.join(';');
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
bodyHash,
|
||||
].join('\n');
|
||||
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
|
||||
|
||||
const requestHeaders = { ...headers, Authorization: auth };
|
||||
delete requestHeaders.host;
|
||||
|
||||
const url = canonicalQueryString
|
||||
? `https://${host}${path}?${canonicalQueryString}`
|
||||
: `https://${host}${path}`;
|
||||
|
||||
return { url, headers: requestHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a presigned URL (signature embedded in query string, no Authorization header).
|
||||
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
|
||||
*/
|
||||
function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
|
||||
const { accessKeyId, secretAccessKey } = config;
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
|
||||
const query = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${accessKeyId}/${scope}`,
|
||||
'X-Amz-Date': ts,
|
||||
'X-Amz-Expires': String(expiresIn),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
`host:${host}\n`,
|
||||
'host',
|
||||
'UNSIGNED-PAYLOAD',
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
|
||||
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getConfig() {
|
||||
const region = process.env.ZEN_STORAGE_REGION;
|
||||
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_BUCKET;
|
||||
|
||||
if (!region || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Storage credentials are not configured. Please set ZEN_STORAGE_REGION, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set');
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
host: `${region}.r2.cloudflarestorage.com`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Minimal XML helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function xmlFirst(xml, tag) {
|
||||
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function xmlAll(xml, tag) {
|
||||
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
|
||||
const results = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) results.push(m[1]);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Body normalizer ─────────────────────────────────────────────────────────
|
||||
|
||||
async function toBuffer(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (typeof body === 'string') return Buffer.from(body, 'utf8');
|
||||
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
|
||||
return Buffer.from(body);
|
||||
}
|
||||
|
||||
// ─── Sanitization helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
|
||||
* A value containing these characters would break the canonical request format
|
||||
* and could allow an attacker to inject arbitrary signed headers.
|
||||
*/
|
||||
function sanitizeHeaderValue(value) {
|
||||
return String(value).replace(/[\r\n\0]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters to prevent injection into the DeleteObjects payload.
|
||||
*/
|
||||
function escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Metadata header helpers ─────────────────────────────────────────────────
|
||||
|
||||
function metaToHeaders(metadata) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).map(([k, v]) => [
|
||||
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
|
||||
sanitizeHeaderValue(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function headersToMeta(headers) {
|
||||
return Object.fromEntries(
|
||||
[...headers.entries()]
|
||||
.filter(([k]) => k.startsWith('x-amz-meta-'))
|
||||
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Storage functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|string|Uint8Array|Blob} options.body - File content
|
||||
* @param {string} options.contentType - MIME type
|
||||
* @param {Object} options.metadata - Optional metadata key-value pairs
|
||||
* @param {string} options.cacheControl - Optional cache control header
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const bodyBuffer = await toBuffer(body);
|
||||
|
||||
const extraHeaders = {
|
||||
'content-type': sanitizeHeaderValue(contentType),
|
||||
...metaToHeaders(metadata),
|
||||
...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }),
|
||||
};
|
||||
|
||||
const { url, headers } = signRequest({
|
||||
method: 'PUT',
|
||||
host: config.host,
|
||||
path,
|
||||
extraHeaders,
|
||||
bodyBuffer,
|
||||
config,
|
||||
date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return { success: true, data: { key, bucket: config.bucket, contentType }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error uploading file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image with optimized settings
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|Blob} options.body - Image content
|
||||
* @param {string} options.contentType - Image MIME type
|
||||
* @param {Object} options.metadata - Optional metadata
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadImage({ key, body, contentType, metadata = {} }) {
|
||||
return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
* @param {string} key - File path/key to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'DELETE', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'DELETE', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Delete failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return { success: true, data: { key }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error deleting file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files from storage
|
||||
* @param {string[]} keys - Array of file paths/keys to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFiles(keys) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
const xmlBody =
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Delete>` +
|
||||
keys.map(k => `<Object><Key>${escapeXml(k)}</Key></Object>`).join('') +
|
||||
`</Delete>`;
|
||||
const bodyBuffer = Buffer.from(xmlBody, 'utf8');
|
||||
const contentMd5 = createHash('md5').update(bodyBuffer).digest('base64');
|
||||
|
||||
const { url, headers } = signRequest({
|
||||
method: 'POST',
|
||||
host: config.host,
|
||||
path,
|
||||
query: { delete: '' },
|
||||
extraHeaders: { 'content-type': 'application/xml', 'content-md5': contentMd5 },
|
||||
bodyBuffer,
|
||||
config,
|
||||
date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, { method: 'POST', headers, body: bodyBuffer });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Delete failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') }));
|
||||
const errors = xmlAll(xml, 'Error').map(b => ({
|
||||
Key: xmlFirst(b, 'Key'),
|
||||
Code: xmlFirst(b, 'Code'),
|
||||
Message: xmlFirst(b, 'Message'),
|
||||
}));
|
||||
|
||||
return { success: true, data: { deleted, errors }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error deleting files:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file from storage
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @returns {Promise<Object>} File data with metadata
|
||||
*/
|
||||
async function getFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Get failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: response.headers.get('content-type'),
|
||||
contentLength: Number(response.headers.get('content-length')),
|
||||
lastModified: response.headers.get('last-modified')
|
||||
? new Date(response.headers.get('last-modified'))
|
||||
: null,
|
||||
metadata: headersToMeta(response.headers),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error getting file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata without downloading the file
|
||||
* @param {string} key - File path/key
|
||||
* @returns {Promise<Object>} File metadata
|
||||
*/
|
||||
async function getFileMetadata(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'HEAD', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'HEAD', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Head failed (${response.status})`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
key,
|
||||
contentType: response.headers.get('content-type'),
|
||||
contentLength: Number(response.headers.get('content-length')),
|
||||
lastModified: response.headers.get('last-modified')
|
||||
? new Date(response.headers.get('last-modified'))
|
||||
: null,
|
||||
metadata: headersToMeta(response.headers),
|
||||
etag: response.headers.get('etag'),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error getting file metadata:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in storage
|
||||
* @param {string} key - File path/key to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(key) {
|
||||
const result = await getFileMetadata(key);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory/prefix
|
||||
* @param {Object} options
|
||||
* @param {string} options.prefix - Directory prefix (e.g., 'users/123/')
|
||||
* @param {number} options.maxKeys - Maximum number of keys to return (default: 1000)
|
||||
* @param {string} options.continuationToken - Token for pagination
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
// R2/S3 max is 1000 keys per list request
|
||||
const validMaxKeys = Math.min(Math.max(Math.floor(Number(maxKeys)), 1), 1000);
|
||||
|
||||
const query = {
|
||||
'list-type': '2',
|
||||
'max-keys': String(validMaxKeys),
|
||||
...(prefix && { prefix }),
|
||||
...(continuationToken && { 'continuation-token': continuationToken }),
|
||||
};
|
||||
|
||||
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date });
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`List failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true';
|
||||
const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken');
|
||||
const files = xmlAll(xml, 'Contents').map(block => ({
|
||||
key: xmlFirst(block, 'Key'),
|
||||
size: parseInt(xmlFirst(block, 'Size') || '0', 10),
|
||||
lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null,
|
||||
etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { files, isTruncated, nextContinuationToken, count: files.length },
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error listing files:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for temporary access to a file
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key
|
||||
* @param {number} options.expiresIn - URL expiration time in seconds (default: 3600)
|
||||
* @param {string} options.operation - 'get' or 'put' (default: 'get')
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const method = operation === 'put' ? 'PUT' : 'GET';
|
||||
|
||||
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
|
||||
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800);
|
||||
if (!Number.isFinite(validExpiresIn)) {
|
||||
throw new Error('expiresIn must be a finite positive number');
|
||||
}
|
||||
|
||||
const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
|
||||
|
||||
return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error generating presigned URL:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within the same bucket
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function copyFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const getResult = await getFile(sourceKey);
|
||||
if (!getResult.success) return getResult;
|
||||
|
||||
const uploadResult = await uploadFile({
|
||||
key: destinationKey,
|
||||
body: getResult.data.body,
|
||||
contentType: getResult.data.contentType,
|
||||
metadata: getResult.data.metadata,
|
||||
});
|
||||
|
||||
if (uploadResult.success) {
|
||||
console.log(`[ZEN STORAGE] File copied from ${sourceKey} to ${destinationKey}`);
|
||||
}
|
||||
|
||||
return uploadResult;
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error copying file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a file from storage, returning a handler-ready response object.
|
||||
* Use this instead of presigned URLs to avoid exposing storage URLs to clients.
|
||||
* The returned object is consumed directly by the API router to stream the file.
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @param {Object} options
|
||||
* @param {string} [options.filename] - Optional download filename (Content-Disposition)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function proxyFile(key, { filename } = {}) {
|
||||
const result = await getFile(key);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
...(filename && { filename }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file (copy + delete source)
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function moveFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const copyResult = await copyFile({ sourceKey, destinationKey });
|
||||
if (!copyResult.success) return copyResult;
|
||||
|
||||
const deleteResult = await deleteFile(sourceKey);
|
||||
if (!deleteResult.success) {
|
||||
console.warn(
|
||||
`[ZEN STORAGE] File copied to ${destinationKey} but failed to delete source ${sourceKey}`
|
||||
);
|
||||
} else {
|
||||
console.log(`[ZEN STORAGE] File moved from ${sourceKey} to ${destinationKey}`);
|
||||
}
|
||||
|
||||
return copyResult;
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error moving file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export utility functions
|
||||
export {
|
||||
generateUniqueFilename,
|
||||
getFileExtension,
|
||||
getMimeType,
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
generateUserFilePath,
|
||||
generateOrgFilePath,
|
||||
generateBlogFilePath,
|
||||
sanitizeFilename,
|
||||
validateImageDimensions,
|
||||
validateUpload,
|
||||
FILE_TYPE_PRESETS,
|
||||
FILE_SIZE_LIMITS,
|
||||
} from './utils.js';
|
||||
|
||||
// Export storage functions
|
||||
export {
|
||||
uploadFile,
|
||||
uploadImage,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFile,
|
||||
getFileMetadata,
|
||||
fileExists,
|
||||
listFiles,
|
||||
getPresignedUrl,
|
||||
proxyFile,
|
||||
copyFile,
|
||||
moveFile,
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Storage utility functions
|
||||
* Helper functions for file handling, validation, and naming
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a unique filename with timestamp and random hash
|
||||
* @param {string} originalName - Original filename
|
||||
* @param {string} prefix - Optional prefix for the filename
|
||||
* @returns {string} Unique filename
|
||||
*/
|
||||
export function generateUniqueFilename(originalName, prefix = '') {
|
||||
const timestamp = Date.now();
|
||||
const randomHash = crypto.randomBytes(8).toString('hex');
|
||||
const extension = getFileExtension(originalName);
|
||||
const basePrefix = prefix ? `${prefix}_` : '';
|
||||
return `${basePrefix}${timestamp}_${randomHash}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} File extension with dot (e.g., '.jpg') or empty string
|
||||
*/
|
||||
export function getFileExtension(filename) {
|
||||
if (!filename) return '';
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
* @param {string} filename - Filename or extension
|
||||
* @returns {string} MIME type
|
||||
*/
|
||||
export function getMimeType(filename) {
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
|
||||
const mimeTypes = {
|
||||
// Images
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.bmp': 'image/bmp',
|
||||
|
||||
// Documents
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
|
||||
// Archives
|
||||
'.zip': 'application/zip',
|
||||
'.rar': 'application/x-rar-compressed',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.tar': 'application/x-tar',
|
||||
'.gz': 'application/gzip',
|
||||
|
||||
// Media
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
'.wmv': 'video/x-ms-wmv',
|
||||
|
||||
// Code
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
};
|
||||
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type against allowed types
|
||||
* @param {string} filename - Filename or extension
|
||||
* @param {string[]} allowedTypes - Array of allowed extensions (e.g., ['.jpg', '.png']) or MIME types
|
||||
* @returns {boolean} True if file type is allowed
|
||||
*/
|
||||
export function validateFileType(filename, allowedTypes) {
|
||||
if (!allowedTypes || allowedTypes.length === 0) return true;
|
||||
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
const mimeType = getMimeType(filename);
|
||||
|
||||
return allowedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return ext === type.toLowerCase();
|
||||
}
|
||||
return mimeType === type;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file size
|
||||
* @param {number} size - File size in bytes
|
||||
* @param {number} maxSize - Maximum allowed size in bytes
|
||||
* @returns {boolean} True if file size is valid
|
||||
*/
|
||||
export function validateFileSize(size, maxSize) {
|
||||
if (!maxSize) return true;
|
||||
return size <= maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human-readable format
|
||||
* @param {number} bytes - Size in bytes
|
||||
* @param {number} decimals - Number of decimal places (default: 2)
|
||||
* @returns {string} Formatted size (e.g., '1.5 MB')
|
||||
*/
|
||||
export function formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for a user's file
|
||||
* @param {string|number} userId - User ID
|
||||
* @param {string} category - File category (e.g., 'profile', 'documents')
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path (e.g., 'users/123/profile/filename.jpg')
|
||||
*/
|
||||
export function generateUserFilePath(userId, category, filename) {
|
||||
return `users/${userId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for organization/tenant files
|
||||
* @param {string|number} orgId - Organization/tenant ID
|
||||
* @param {string} category - File category
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path
|
||||
*/
|
||||
export function generateOrgFilePath(orgId, category, filename) {
|
||||
return `organizations/${orgId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for blog post images
|
||||
* @param {string|number} postIdOrSlug - Post ID or slug (e.g. for temp uploads use timestamp)
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path (e.g., 'blog/123/filename.jpg')
|
||||
*/
|
||||
export function generateBlogFilePath(postIdOrSlug, filename) {
|
||||
return `blog/${postIdOrSlug}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename by removing special characters
|
||||
* @param {string} filename - Original filename
|
||||
* @returns {string} Sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename) {
|
||||
const ext = getFileExtension(filename);
|
||||
const nameWithoutExt = filename.substring(0, filename.length - ext.length);
|
||||
|
||||
// Remove special characters and replace spaces with underscores
|
||||
const sanitized = nameWithoutExt
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
return sanitized + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image dimensions from buffer
|
||||
* Note: This is a basic implementation. For production, consider using a library like 'sharp'
|
||||
* @param {Buffer} buffer - Image buffer
|
||||
* @param {Object} constraints - Dimension constraints
|
||||
* @param {number} constraints.maxWidth - Maximum width
|
||||
* @param {number} constraints.maxHeight - Maximum height
|
||||
* @param {number} constraints.minWidth - Minimum width
|
||||
* @param {number} constraints.minHeight - Minimum height
|
||||
* @returns {Promise<Object>} Validation result with dimensions
|
||||
*/
|
||||
export async function validateImageDimensions(buffer, constraints = {}) {
|
||||
// This is a placeholder - in production, use a library like 'sharp'
|
||||
// For now, we'll return a basic structure
|
||||
return {
|
||||
valid: true,
|
||||
width: null,
|
||||
height: null,
|
||||
message: 'Image dimension validation requires additional setup',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common file type presets
|
||||
*/
|
||||
export const FILE_TYPE_PRESETS = {
|
||||
IMAGES: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
IMAGES_NO_GIF: ['.jpg', '.jpeg', '.png', '.webp'],
|
||||
DOCUMENTS: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv'],
|
||||
PDF_ONLY: ['.pdf'],
|
||||
VIDEOS: ['.mp4', '.avi', '.mov', '.wmv'],
|
||||
AUDIO: ['.mp3', '.wav'],
|
||||
ARCHIVES: ['.zip', '.rar', '.7z', '.tar', '.gz'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Common file size limits (in bytes)
|
||||
*/
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
AVATAR: 5 * 1024 * 1024, // 5 MB
|
||||
IMAGE: 10 * 1024 * 1024, // 10 MB
|
||||
DOCUMENT: 50 * 1024 * 1024, // 50 MB
|
||||
VIDEO: 500 * 1024 * 1024, // 500 MB
|
||||
LARGE_FILE: 1024 * 1024 * 1024, // 1 GB
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate upload file
|
||||
* @param {Object} options - Validation options
|
||||
* @param {string} options.filename - Filename
|
||||
* @param {number} options.size - File size in bytes
|
||||
* @param {string[]} options.allowedTypes - Allowed file types
|
||||
* @param {number} options.maxSize - Maximum file size
|
||||
* @returns {Object} Validation result
|
||||
*/
|
||||
export function validateUpload({ filename, size, allowedTypes, maxSize }) {
|
||||
const errors = [];
|
||||
|
||||
if (!filename) {
|
||||
errors.push('Filename is required');
|
||||
}
|
||||
|
||||
if (allowedTypes && !validateFileType(filename, allowedTypes)) {
|
||||
const typesList = allowedTypes.join(', ');
|
||||
errors.push(`File type not allowed. Allowed types: ${typesList}`);
|
||||
}
|
||||
|
||||
if (maxSize && !validateFileSize(size, maxSize)) {
|
||||
errors.push(`File size exceeds limit of ${formatFileSize(maxSize)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Tick02Icon,
|
||||
Cancel01Icon,
|
||||
AlertCircleIcon,
|
||||
InformationCircleIcon,
|
||||
CancelCircleIcon
|
||||
} from '../../shared/Icons.js';
|
||||
|
||||
const Toast = ({
|
||||
id,
|
||||
type = 'info',
|
||||
message,
|
||||
title,
|
||||
duration = 5000,
|
||||
dismissible = true,
|
||||
isAutoRemoving = false,
|
||||
onDismiss
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger entrance animation
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger exit animation on auto-remove
|
||||
if (isAutoRemoving && !isLeaving) {
|
||||
setIsLeaving(true);
|
||||
}
|
||||
}, [isAutoRemoving, isLeaving]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!dismissible) return;
|
||||
|
||||
setIsLeaving(true);
|
||||
setTimeout(() => {
|
||||
onDismiss(id);
|
||||
}, 300); // Match animation duration
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <Tick02Icon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'error':
|
||||
return <Cancel01Icon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'warning':
|
||||
return <AlertCircleIcon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'info':
|
||||
default:
|
||||
return <InformationCircleIcon className="h-5 w-5 flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
const base = 'backdrop-blur-sm shadow-lg transition-colors duration-200';
|
||||
const shadow = 'shadow-neutral-900/10 dark:shadow-black/20';
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `${base} ${shadow} bg-green-50 border border-green-200 text-green-700 hover:bg-green-100/80 dark:bg-green-500/10 dark:border-green-500/20 dark:text-green-400 dark:hover:bg-green-500/15`;
|
||||
case 'error':
|
||||
return `${base} ${shadow} bg-red-50 border border-red-200 text-red-700 hover:bg-red-100/80 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-400 dark:hover:bg-red-500/15`;
|
||||
case 'warning':
|
||||
return `${base} ${shadow} bg-yellow-50 border border-yellow-200 text-yellow-800 hover:bg-yellow-100/80 dark:bg-yellow-500/10 dark:border-yellow-500/20 dark:text-yellow-400 dark:hover:bg-yellow-500/15`;
|
||||
case 'info':
|
||||
default:
|
||||
return `${base} ${shadow} bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-500/10 dark:border-blue-500/20 dark:text-blue-400 dark:hover:bg-blue-500/15`;
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'Success';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'warning':
|
||||
return 'Warning';
|
||||
case 'info':
|
||||
default:
|
||||
return 'Information';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${getStyles()}
|
||||
px-4 py-3 min-w-[300px] max-w-[400px]
|
||||
rounded-xl flex items-start gap-3
|
||||
transition-all duration-300 ease-in-out
|
||||
transform
|
||||
${isVisible && !isLeaving
|
||||
? 'translate-x-0 opacity-100 scale-100'
|
||||
: 'translate-x-8 opacity-0 scale-95'
|
||||
}
|
||||
${isLeaving ? 'translate-x-8 opacity-0 scale-95' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">{getTitle()}</p>
|
||||
<p className="text-xs opacity-90 dark:opacity-95 mt-0.5 break-words">{message}</p>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 mt-0.5 opacity-60 hover:opacity-100 dark:opacity-70 dark:hover:opacity-100 transition-opacity duration-200 text-current"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<CancelCircleIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useToast } from './ToastContext';
|
||||
import Toast from './Toast';
|
||||
|
||||
const ToastContainer = ({ maxToasts = 5 }) => {
|
||||
const { toasts, removeToast } = useToast();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [toastHeights, setToastHeights] = useState({});
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const toastRefs = useRef({});
|
||||
|
||||
// Limit the number of visible toasts
|
||||
const visibleToasts = toasts.slice(-maxToasts);
|
||||
|
||||
// Measure toast heights
|
||||
useEffect(() => {
|
||||
const newHeights = {};
|
||||
visibleToasts.forEach((toast) => {
|
||||
const element = toastRefs.current[toast.id];
|
||||
if (element) {
|
||||
newHeights[toast.id] = element.offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Update only if heights have changed
|
||||
const hasChanged = visibleToasts.some(toast =>
|
||||
newHeights[toast.id] !== toastHeights[toast.id]
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
setToastHeights(newHeights);
|
||||
}
|
||||
}, [visibleToasts, toastHeights]);
|
||||
|
||||
const handleMouseEnter = (isLastToast) => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Trigger hover only on most recent toast OR if already in hover mode
|
||||
if (isLastToast || isHovered) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Delay before closing to avoid flickering
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Calculate position of each toast based on actual heights
|
||||
const calculatePosition = (index) => {
|
||||
const isRecent = index === visibleToasts.length - 1;
|
||||
const distanceFromRecent = visibleToasts.length - 1 - index;
|
||||
|
||||
if (isRecent) {
|
||||
return 0; // Most recent stays at 0
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
// In hover mode: add heights of more recent toasts + margin
|
||||
let totalOffset = 0;
|
||||
for (let i = index + 1; i < visibleToasts.length; i++) {
|
||||
const toastId = visibleToasts[i].id;
|
||||
const height = toastHeights[toastId] || 60; // default height
|
||||
totalOffset += height + 10; // height + 10px margin
|
||||
}
|
||||
return -totalOffset;
|
||||
} else {
|
||||
// In stack mode: reduced spacing based on height
|
||||
const recentToastHeight = toastHeights[visibleToasts[visibleToasts.length - 1].id] || 60;
|
||||
return -(distanceFromRecent * Math.min(recentToastHeight * 0.15, 12)); // Maximum 12px per level
|
||||
}
|
||||
};
|
||||
|
||||
if (visibleToasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-4 right-4
|
||||
z-50 pointer-events-none
|
||||
transition-all duration-300 ease-in-out
|
||||
`}
|
||||
>
|
||||
<div className="relative">
|
||||
{visibleToasts.map((toast, index) => {
|
||||
// The last toast (index length-1) is always the most recent
|
||||
const isRecent = index === visibleToasts.length - 1;
|
||||
const distanceFromRecent = visibleToasts.length - 1 - index;
|
||||
|
||||
// Calculations for both modes
|
||||
const scale = isHovered ? 1 : (isRecent ? 1 : Math.max(0.7, 1 - (distanceFromRecent * 0.08)));
|
||||
const translateY = calculatePosition(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
ref={el => toastRefs.current[toast.id] = el}
|
||||
className={`
|
||||
absolute bottom-0 right-0
|
||||
pointer-events-auto
|
||||
transition-all duration-500 ease-out
|
||||
`}
|
||||
style={{
|
||||
transform: `scale(${scale}) translateY(${translateY}px)`,
|
||||
zIndex: isRecent ? 10 : (10 - distanceFromRecent),
|
||||
}}
|
||||
onMouseEnter={() => handleMouseEnter(isRecent)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Toast
|
||||
{...toast}
|
||||
onDismiss={removeToast}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
const ToastContext = createContext();
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((toast) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const newToast = {
|
||||
id,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
dismissible: true,
|
||||
isAutoRemoving: false,
|
||||
...toast,
|
||||
};
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
if (newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
// First mark the toast for auto-removal
|
||||
setToasts(prev => prev.map(t =>
|
||||
t.id === id ? { ...t, isAutoRemoving: true } : t
|
||||
));
|
||||
|
||||
// Then remove it after animation (300ms)
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 300);
|
||||
}, newToast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearAllToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
// Convenience methods for different toast types
|
||||
const success = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'success',
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const error = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'error',
|
||||
message,
|
||||
duration: 7000, // Longer duration for errors
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const warning = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'warning',
|
||||
message,
|
||||
duration: 6000,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const info = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'info',
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
clearAllToasts,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContext;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
export { default as Toast } from './Toast';
|
||||
export { ToastProvider, useToast } from './ToastContext';
|
||||
export { default as ToastContainer } from './ToastContainer';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Admin Server Actions
|
||||
*
|
||||
* These are exported separately from admin/index.js to avoid bundling
|
||||
* server-side code (which includes database imports) into client components.
|
||||
*
|
||||
* Usage:
|
||||
* import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
|
||||
*/
|
||||
|
||||
export { getDashboardStats } from './actions/statsActions.js';
|
||||
export { getAllModuleDashboardStats as getModuleDashboardStats } from '@hykocx/zen/modules/actions';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Admin Stats Actions
|
||||
* Server-side actions for core dashboard statistics
|
||||
*
|
||||
* Module-specific stats are handled by each module's dashboard actions.
|
||||
* See src/modules/{module}/dashboard/statsActions.js
|
||||
*
|
||||
* Usage in your Next.js app:
|
||||
*
|
||||
* ```javascript
|
||||
* // app/(admin)/admin/[...admin]/page.js
|
||||
* import { protectAdmin } from '@hykocx/zen/admin';
|
||||
* import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
|
||||
* import { AdminPagesClient } from '@hykocx/zen/admin/pages';
|
||||
*
|
||||
* export default async function AdminPage({ params }) {
|
||||
* const { user } = await protectAdmin();
|
||||
*
|
||||
* // Fetch core dashboard stats
|
||||
* const statsResult = await getDashboardStats();
|
||||
* const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||
*
|
||||
* // Fetch module dashboard stats (for dynamic widgets)
|
||||
* const moduleStats = await getModuleDashboardStats();
|
||||
*
|
||||
* return (
|
||||
* <AdminPagesClient
|
||||
* params={params}
|
||||
* user={user}
|
||||
* dashboardStats={dashboardStats}
|
||||
* moduleStats={moduleStats}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
/**
|
||||
* Get total number of users
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function getTotalUsersCount() {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) as count FROM zen_auth_users`
|
||||
);
|
||||
return parseInt(result.rows[0].count) || 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting users count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get core dashboard statistics
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getDashboardStats() {
|
||||
try {
|
||||
const totalUsers = await getTotalUsersCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
totalUsers,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting dashboard stats:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to get dashboard statistics'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronDownIcon } from '../../../shared/Icons.js';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
const AdminHeader = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN' }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
if (onLogout) {
|
||||
const result = await onLogout();
|
||||
if (result && result.success) {
|
||||
router.push('/auth/login');
|
||||
} else {
|
||||
console.error('Logout failed:', result?.error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const quickLinks = [];
|
||||
|
||||
const userInitials = getUserInitials(user?.name);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-14 flex items-center w-full">
|
||||
<div className="flex items-center justify-between lg:justify-end px-4 lg:px-6 py-2 w-full">
|
||||
{/* Left section - Mobile menu button + Logo (hidden on desktop) */}
|
||||
<div className="flex items-center space-x-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 rounded-lg bg-neutral-100 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700/50 text-neutral-900 dark:text-white hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors duration-200"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Theme Toggle + Quick Links + Profile */}
|
||||
<div className="flex items-center space-x-3 sm:space-x-4">
|
||||
{/* Quick Links - Hidden on very small screens */}
|
||||
<nav className="hidden sm:flex items-center space-x-4 lg:space-x-6">
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Profile Menu */}
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="cursor-pointer flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-200 group ui-open:bg-neutral-100 dark:ui-open:bg-neutral-800/50 outline-none">
|
||||
{/* Avatar for desktop - hidden on mobile */}
|
||||
<div className="hidden sm:flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Avatar for mobile - visible on mobile only */}
|
||||
<div className="sm:hidden">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 text-neutral-400 transition-transform duration-200 ui-open:rotate-180" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 -translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-56 sm:w-64 bg-white dark:bg-neutral-900/95 backdrop-blur-sm border border-neutral-200 dark:border-neutral-700/50 rounded-xl shadow-xl z-50 outline-none">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-neutral-400">{user?.email || 'email@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links for mobile */}
|
||||
{quickLinks.length > 0 && (
|
||||
<div className="sm:hidden py-2 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
{quickLinks.map((link) => (
|
||||
<Menu.Item key={link.name}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={link.href}
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{link.name}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="/admin/profile"
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 group ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Mon profil
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700/50 mt-2 pt-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm transition-all duration-200 group text-left ${
|
||||
active
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Se déconnecter
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHeader;
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Pages Component
|
||||
*
|
||||
* This component handles both core admin pages and module pages.
|
||||
* Module pages are loaded dynamically on the client where hooks work properly.
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import DashboardPage from './pages/DashboardPage.js';
|
||||
import UsersPage from './pages/UsersPage.js';
|
||||
import UserEditPage from './pages/UserEditPage.js';
|
||||
import ProfilePage from './pages/ProfilePage.js';
|
||||
import { getModulePageLoader } from '../../../modules/modules.pages.js';
|
||||
|
||||
// Loading component for suspense
|
||||
function PageLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPagesClient({
|
||||
params,
|
||||
user,
|
||||
dashboardStats = null,
|
||||
moduleStats = {},
|
||||
modulePageInfo = null,
|
||||
routeInfo = null,
|
||||
enabledModules = {}
|
||||
}) {
|
||||
// If this is a module page, render it with lazy loading
|
||||
if (modulePageInfo && routeInfo) {
|
||||
const LazyComponent = getModulePageLoader(modulePageInfo.module, modulePageInfo.path);
|
||||
if (LazyComponent) {
|
||||
// Build props for the page
|
||||
const pageProps = { user };
|
||||
if (routeInfo.action === 'edit' && routeInfo.id) {
|
||||
// Add ID props for edit pages (modules may use different prop names)
|
||||
pageProps.id = routeInfo.id;
|
||||
pageProps.invoiceId = routeInfo.id;
|
||||
pageProps.clientId = routeInfo.id;
|
||||
pageProps.itemId = routeInfo.id;
|
||||
pageProps.categoryId = routeInfo.id;
|
||||
pageProps.transactionId = routeInfo.id;
|
||||
pageProps.recurrenceId = routeInfo.id;
|
||||
pageProps.templateId = routeInfo.id;
|
||||
pageProps.postId = routeInfo.id;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<LazyComponent {...pageProps} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine core page from routeInfo or params
|
||||
let currentPage = 'dashboard';
|
||||
if (routeInfo?.path) {
|
||||
const parts = routeInfo.path.split('/').filter(Boolean);
|
||||
currentPage = parts[1] || 'dashboard'; // /admin/[page]
|
||||
} else if (params?.admin) {
|
||||
currentPage = params.admin[0] || 'dashboard';
|
||||
}
|
||||
|
||||
// Core page components mapping (non-module pages)
|
||||
const usersPageComponent = routeInfo?.action === 'edit' && routeInfo?.id
|
||||
? () => <UserEditPage userId={routeInfo.id} user={user} enabledModules={enabledModules} />
|
||||
: () => <UsersPage user={user} />;
|
||||
|
||||
const corePages = {
|
||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />,
|
||||
users: usersPageComponent,
|
||||
profile: () => <ProfilePage user={user} />,
|
||||
};
|
||||
|
||||
// Render the appropriate core page or default to dashboard
|
||||
const CorePageComponent = corePages[currentPage];
|
||||
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import { useState } from 'react';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-white dark:bg-black">
|
||||
<AdminSidebar
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
setIsMobileMenuOpen={setIsMobileMenuOpen}
|
||||
appName={appName}
|
||||
enabledModules={enabledModules}
|
||||
navigationSections={navigationSections}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} />
|
||||
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
|
||||
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 pt-4 sm:pt-6 lg:pt-8 pb-32 max-w-[1400px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import * as Icons from '../../../shared/Icons.js';
|
||||
import { ChevronDownIcon } from '../../../shared/Icons.js';
|
||||
|
||||
/**
|
||||
* Resolve icon name (string) to icon component
|
||||
* Icons are passed as strings from server to avoid serialization issues
|
||||
*/
|
||||
function resolveIcon(iconNameOrComponent) {
|
||||
// If it's already a component (function), return it
|
||||
if (typeof iconNameOrComponent === 'function') {
|
||||
return iconNameOrComponent;
|
||||
}
|
||||
// If it's a string, look up in Icons
|
||||
if (typeof iconNameOrComponent === 'string') {
|
||||
return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon;
|
||||
}
|
||||
// Default fallback
|
||||
return Icons.DashboardSquare03Icon;
|
||||
}
|
||||
|
||||
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// State to manage collapsed sections (all open by default)
|
||||
const [collapsedSections, setCollapsedSections] = useState(new Set());
|
||||
|
||||
// Function to toggle a section's state
|
||||
const toggleSection = (sectionId) => {
|
||||
// Find the section to check if it has active items
|
||||
const section = navigationSections.find(s => s.id === sectionId);
|
||||
|
||||
// Don't allow collapsing sections with active items
|
||||
if (section && isSectionActive(section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedSections(prev => {
|
||||
const newCollapsed = new Set(prev);
|
||||
if (newCollapsed.has(sectionId)) {
|
||||
newCollapsed.delete(sectionId);
|
||||
} else {
|
||||
newCollapsed.add(sectionId);
|
||||
}
|
||||
return newCollapsed;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mobile menu closure when clicking on a link
|
||||
const handleMobileLinkClick = () => {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
// Close mobile menu on screen size change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 1024) { // lg breakpoint
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [setIsMobileMenuOpen]);
|
||||
|
||||
// Function to check if any item in a section is currently active
|
||||
const isSectionActive = (section) => {
|
||||
return section.items.some(item => item.current);
|
||||
};
|
||||
|
||||
// Function to check if a section should be rendered as a direct link
|
||||
const shouldRenderAsDirectLink = (section) => {
|
||||
// Check if there's only one item and it has the same name as the section
|
||||
return section.items.length === 1 &&
|
||||
section.items[0].name.toLowerCase() === section.title.toLowerCase();
|
||||
};
|
||||
|
||||
// Update collapsed sections when pathname changes to ensure active sections are open
|
||||
useEffect(() => {
|
||||
setCollapsedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
// Add any sections that have active items to ensure they stay open
|
||||
navigationSections.forEach(section => {
|
||||
if (isSectionActive(section)) {
|
||||
newSet.add(section.id);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
// Use server-provided navigation sections if available, otherwise use core-only fallback
|
||||
// Server navigation includes module navigation, fallback only has core pages
|
||||
// Update the 'current' property based on the actual pathname (client-side)
|
||||
const navigationSections = serverNavigationSections.map(section => ({
|
||||
...section,
|
||||
items: section.items.map(item => ({
|
||||
...item,
|
||||
current: pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
}))
|
||||
}));
|
||||
|
||||
// Function to render a complete navigation section
|
||||
const renderNavSection = (section) => {
|
||||
const Icon = resolveIcon(section.icon);
|
||||
|
||||
// If section should be rendered as a direct link
|
||||
if (shouldRenderAsDirectLink(section)) {
|
||||
const item = section.items[0];
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-900 dark:text-white hover:text-neutral-500 dark:hover:text-neutral-300'
|
||||
} w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] tracking-wide transition-colorsduration-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular section with expandable sub-items
|
||||
const isCollapsed = !collapsedSections.has(section.id);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="cursor-pointer w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] text-neutral-900 dark:text-white tracking-wide hover:text-neutral-500 dark:hover:text-neutral-300 transition-colorsduration-0"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden ${
|
||||
isCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[1000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
<ul className="flex flex-col gap-0">
|
||||
{section.items.map(renderNavItem)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to render a navigation item
|
||||
const renderNavItem = (item) => {
|
||||
const Icon = resolveIcon(item.icon);
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white'
|
||||
} group flex items-center justify-between px-4 py-1.5 text-[12px] font-medium transition-allduration-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-3.5 w-3.5 flex-shrink-0" />
|
||||
{item.name}
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-300 ease-in-out
|
||||
`}>
|
||||
{/* Logo Section */}
|
||||
<Link href="/admin" className="px-4 h-14 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold">{appName}</h1>
|
||||
<span className="bg-red-700/10 border border-red-600/20 text-red-600 uppercase text-[10px] leading-none px-2 py-1 rounded-full font-semibold">
|
||||
Admin
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-0 overflow-y-auto flex flex-col gap-0 pb-12 -mt-[1px]">
|
||||
{navigationSections.map(renderNavSection)}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSidebar;
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sun01Icon, Moon02Icon, SunCloud02Icon, MoonCloudIcon } from '../../../shared/Icons.js';
|
||||
|
||||
function getNextTheme(current) {
|
||||
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (current === 'auto') return systemIsDark ? 'light' : 'dark';
|
||||
if (current === 'dark') return systemIsDark ? 'auto' : 'light';
|
||||
return systemIsDark ? 'dark' : 'auto';
|
||||
}
|
||||
|
||||
function getAutoIcon(systemIsDark) {
|
||||
return systemIsDark ? MoonCloudIcon : SunCloud02Icon;
|
||||
}
|
||||
|
||||
const THEME_ICONS = {
|
||||
light: Sun01Icon,
|
||||
dark: Moon02Icon,
|
||||
};
|
||||
|
||||
function getStoredTheme() {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
localStorage.removeItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', prefersDark);
|
||||
}
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState('auto');
|
||||
const [systemIsDark, setSystemIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getStoredTheme());
|
||||
setSystemIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e) {
|
||||
setSystemIsDark(e.matches);
|
||||
if (localStorage.getItem('theme')) return;
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
}
|
||||
mq.addEventListener('change', onSystemChange);
|
||||
return () => mq.removeEventListener('change', onSystemChange);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = getNextTheme(theme);
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
return { theme, toggle, systemIsDark };
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggle, systemIsDark } = useTheme();
|
||||
const Icon = theme === 'auto' ? getAutoIcon(systemIsDark) : THEME_ICONS[theme];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Changer le thème"
|
||||
title="Changer le thème"
|
||||
className="cursor-pointer p-2 rounded-lg text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Admin Components Exports
|
||||
*/
|
||||
|
||||
export { default as AdminPagesClient } from './AdminPages.js';
|
||||
export { default as AdminPagesLayout } from './AdminPagesLayout.js';
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Dashboard Page
|
||||
* Displays core stats and dynamically loads module dashboard widgets
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { StatCard } from '../../../../shared/components';
|
||||
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
|
||||
import { getModuleDashboardWidgets } from '../../../../modules/modules.pages.js';
|
||||
|
||||
/**
|
||||
* Loading placeholder for widgets
|
||||
*/
|
||||
function WidgetLoading() {
|
||||
return (
|
||||
<div className="animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-lg h-32"></div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage({ user, stats, moduleStats = {}, enabledModules = {} }) {
|
||||
const loading = !stats;
|
||||
|
||||
// Get only enabled module dashboard widgets
|
||||
const allModuleWidgets = getModuleDashboardWidgets();
|
||||
const moduleWidgets = Object.fromEntries(
|
||||
Object.entries(allModuleWidgets).filter(([moduleName]) => enabledModules[moduleName])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Vue d'ensemble de votre application</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Module dashboard widgets (dynamically loaded) */}
|
||||
{Object.entries(moduleWidgets).map(([moduleName, widgets]) => (
|
||||
widgets.map((Widget, index) => (
|
||||
<Suspense key={`${moduleName}-widget-${index}`} fallback={<WidgetLoading />}>
|
||||
<Widget stats={moduleStats[moduleName]} />
|
||||
</Suspense>
|
||||
))
|
||||
))}
|
||||
|
||||
{/* Core stats - always shown */}
|
||||
<StatCard
|
||||
title="Nombre d'utilisateurs"
|
||||
value={loading ? '-' : String(stats?.totalUsers || 0)}
|
||||
icon={UserMultiple02Icon}
|
||||
color="text-purple-400"
|
||||
bgColor="bg-purple-500/10"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card, Input, Button } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
const ProfilePage = ({ user: initialUser }) => {
|
||||
const toast = useToast();
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialUser?.name || ''
|
||||
});
|
||||
|
||||
// Helper function to get image URL from storage key
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setFormData({
|
||||
name: initialUser.name || ''
|
||||
});
|
||||
setImagePreview(getImageUrl(initialUser.image));
|
||||
}
|
||||
}, [initialUser]);
|
||||
|
||||
const handleChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Échec de la mise à jour du profil');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
toast.success('Profil mis à jour avec succès');
|
||||
|
||||
// Refresh the page to update the user data in the header
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast.error(error.message || 'Échec de la mise à jour du profil');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: user?.name || ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner un fichier image');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("L'image doit faire moins de 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec du téléchargement de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(getImageUrl(data.user.image));
|
||||
toast.success('Photo de profil mise à jour avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
toast.error(error.message || 'Échec du téléchargement de l\'image');
|
||||
// Revert preview on error
|
||||
setImagePreview(getImageUrl(user?.image));
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async () => {
|
||||
if (!user?.image) return;
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec de la suppression de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(null);
|
||||
toast.success('Photo de profil supprimée avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error removing image:', error);
|
||||
toast.error(error.message || 'Échec de la suppression de l\'image');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const hasChanges = formData.name !== user?.name;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Mon profil
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Gérez les informations de votre compte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Photo de profil
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-800 dark:to-neutral-700 rounded-full flex items-center justify-center border-2 border-neutral-300 dark:border-neutral-700">
|
||||
<span className="text-neutral-700 dark:text-white font-semibold text-2xl">
|
||||
{getUserInitials(user?.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingImage && (
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
||||
</Button>
|
||||
{imagePreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Informations personnelles
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Entrez votre nom complet"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Courriel"
|
||||
name="email"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
readOnly
|
||||
description="L'email ne peut pas être modifié"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Compte créé"
|
||||
name="createdAt"
|
||||
type="text"
|
||||
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) : 'N/D'}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={loading || !hasChanges}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !hasChanges}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Enregistrement...' : 'Enregistrer les modifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Input, Select, Loading } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* User Edit Page Component
|
||||
* Page for editing an existing user (admin only)
|
||||
*/
|
||||
const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const clientsModuleActive = Boolean(enabledModules?.clients);
|
||||
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
role: 'user',
|
||||
email_verified: 'false',
|
||||
client_id: ''
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'user', label: 'Utilisateur' },
|
||||
{ value: 'admin', label: 'Admin' }
|
||||
];
|
||||
|
||||
const emailVerifiedOptions = [
|
||||
{ value: 'false', label: 'Non vérifié' },
|
||||
{ value: 'true', label: 'Vérifié' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientsModuleActive) {
|
||||
fetch('/zen/api/admin/clients?limit=500', { credentials: 'include' })
|
||||
.then(res => res.json())
|
||||
.then(data => data.clients ? setClients(data.clients) : setClients([]))
|
||||
.catch(() => setClients([]));
|
||||
}
|
||||
}, [clientsModuleActive]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.user) {
|
||||
setUserData(data.user);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.user.name || '',
|
||||
role: data.user.role || 'user',
|
||||
email_verified: data.user.email_verified ? 'true' : 'false',
|
||||
client_id: data.linkedClient ? String(data.linkedClient.id) : ''
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.message || 'Utilisateur introuvable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user:', error);
|
||||
toast.error('Impossible de charger l\'utilisateur');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name || !formData.name.trim()) {
|
||||
newErrors.name = 'Le nom est requis';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
role: formData.role,
|
||||
email_verified: formData.email_verified === 'true',
|
||||
...(clientsModuleActive && { client_id: formData.client_id ? parseInt(formData.client_id, 10) : null })
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Utilisateur mis à jour avec succès');
|
||||
router.push('/admin/users');
|
||||
} else {
|
||||
toast.error(data.message || data.error || 'Impossible de mettre à jour l\'utilisateur');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
toast.error('Impossible de mettre à jour l\'utilisateur');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-64 flex items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Utilisateur introuvable</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
|
||||
<p className="font-medium">Utilisateur introuvable</p>
|
||||
<p className="text-sm mt-1">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">{userData.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'utilisateur</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom *"
|
||||
value={formData.name}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
value={userData.email}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rôle"
|
||||
value={formData.role}
|
||||
onChange={(value) => handleInputChange('role', value)}
|
||||
options={roleOptions}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Email vérifié"
|
||||
value={formData.email_verified}
|
||||
onChange={(value) => handleInputChange('email_verified', value)}
|
||||
options={emailVerifiedOptions}
|
||||
/>
|
||||
|
||||
{clientsModuleActive && (
|
||||
<Select
|
||||
label="Client associé"
|
||||
value={formData.client_id}
|
||||
onChange={(value) => handleInputChange('client_id', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Aucun' },
|
||||
...clients.map(c => ({
|
||||
value: String(c.id),
|
||||
label: [c.client_number, c.company_name || [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email].filter(Boolean).join(' – ')
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
disabled={saving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Enregistrement...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEditPage;
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, Table, StatusBadge, Pagination, Button } from '../../../../shared/components';
|
||||
import { PencilEdit01Icon } from '../../../../shared/Icons.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
const UsersPageClient = () => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
// Sort state
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
// Table columns configuration
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nom',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">{user.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">ID: {user.id.slice(0, 8)}...</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: {
|
||||
height: 'h-4', width: '60%',
|
||||
secondary: { height: 'h-3', width: '40%' }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
sortable: true,
|
||||
render: (user) => <div className="text-sm font-medium text-neutral-900 dark:text-white">{user.email}</div>,
|
||||
skeleton: {
|
||||
height: 'h-4',
|
||||
width: '60%',
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Rôle',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.role} />,
|
||||
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'email_verified',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.email_verified ? 'verified' : 'unverified'} />,
|
||||
skeleton: { height: 'h-6', width: '90px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Créé le',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<span className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{formatDate(user.created_at)}
|
||||
</span>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '70%' }
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
noWrap: true,
|
||||
render: (user) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/users/edit/${user.id}`)}
|
||||
icon={<PencilEdit01Icon className="w-4 h-4" />}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }
|
||||
}
|
||||
];
|
||||
|
||||
// Fetch users function
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const response = await fetch(`/zen/api/users?${searchParams}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
page: data.pagination.page
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Impossible de charger les utilisateurs');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to fetch users when sort or pagination change
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
limit: newLimit,
|
||||
page: 1 // Reset to first page when changing limit
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (newSortBy) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
};
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Users Table */}
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
emptyMessage="Aucun utilisateur trouvé"
|
||||
emptyDescription="La base de données est vide"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
limit={pagination.limit}
|
||||
total={pagination.total}
|
||||
loading={loading}
|
||||
showPerPage={true}
|
||||
showStats={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Utilisateurs</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Gérez les comptes utilisateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
<UsersPageClient />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Zen Admin Module
|
||||
* Admin panel functionality with role-based access control
|
||||
*/
|
||||
|
||||
// Middleware exports
|
||||
export { protectAdmin, isAdmin } from './middleware/protect.js';
|
||||
|
||||
// Component exports (for catch-all routes)
|
||||
export { AdminPagesClient, AdminPagesLayout } from './pages.js';
|
||||
|
||||
// NOTE: Server-only navigation builder is in '@hykocx/zen/admin/navigation'
|
||||
// Do NOT import from this file to avoid bundling database code into client
|
||||
|
||||
// NOTE: Admin server actions are exported separately to avoid bundling issues
|
||||
// Import them from '@hykocx/zen/admin/actions' instead
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Admin Route Protection Middleware
|
||||
* Utilities to protect admin routes and require admin role
|
||||
*/
|
||||
|
||||
import { getSession } from '../../auth/actions/authActions.js';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Protect an admin page - requires authentication and admin role
|
||||
* Use this in server components to require admin access
|
||||
*
|
||||
* @param {Object} options - Protection options
|
||||
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
|
||||
* @param {string} options.forbiddenRedirect - Where to redirect if not admin (default: '/')
|
||||
* @returns {Promise<Object>} Session object with user data
|
||||
*
|
||||
* @example
|
||||
* // In a server component:
|
||||
* import { protectAdmin } from '@hykocx/zen/admin';
|
||||
*
|
||||
* export default async function AdminPage() {
|
||||
* const session = await protectAdmin();
|
||||
* return <div>Welcome Admin, {session.user.name}!</div>;
|
||||
* }
|
||||
*/
|
||||
async function protectAdmin(options = {}) {
|
||||
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(redirectTo);
|
||||
}
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
redirect(forbiddenRedirect);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
* Use this when you want to check admin status without forcing a redirect
|
||||
*
|
||||
* @returns {Promise<boolean>} True if user is admin
|
||||
*
|
||||
* @example
|
||||
* import { isAdmin } from '@hykocx/zen/admin';
|
||||
*
|
||||
* export default async function Page() {
|
||||
* const admin = await isAdmin();
|
||||
* return admin ? <div>Admin panel</div> : <div>Access denied</div>;
|
||||
* }
|
||||
*/
|
||||
async function isAdmin() {
|
||||
const session = await getSession();
|
||||
return session && session.user.role === 'admin';
|
||||
}
|
||||
|
||||
export {
|
||||
protectAdmin,
|
||||
isAdmin
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Admin Navigation Builder (Server-Only)
|
||||
*
|
||||
* This file imports from the module registry and should ONLY be used on the server.
|
||||
* It builds the complete navigation including dynamic module navigation.
|
||||
*
|
||||
* IMPORTANT: This file is NOT bundled to ensure it shares the same registry instance
|
||||
* that was populated during module discovery.
|
||||
*
|
||||
* IMPORTANT: We import from '@hykocx/zen' (main package) to use the same registry
|
||||
* instance that was populated during initializeZen(). DO NOT import from
|
||||
* '@hykocx/zen/core/modules' as that's a separate bundle with its own registry.
|
||||
*
|
||||
* IMPORTANT: Navigation data must be serializable (no functions/components).
|
||||
* Icons are passed as string names and resolved on the client.
|
||||
*/
|
||||
|
||||
// Import from the main package to use the same registry as discovery
|
||||
import { moduleSystem } from '@hykocx/zen';
|
||||
const { getAllAdminNavigation } = moduleSystem;
|
||||
|
||||
/**
|
||||
* Build complete navigation sections including modules
|
||||
* This should ONLY be called on the server (in page.js)
|
||||
* @param {string} pathname - Current pathname
|
||||
* @param {Object} enabledModules - Object with module names as keys (for compatibility)
|
||||
* @returns {Array} Complete navigation sections (serializable, icons as strings)
|
||||
*/
|
||||
export function buildNavigationSections(pathname, enabledModules = null) {
|
||||
// Core navigation sections (always available)
|
||||
// Use icon NAMES (strings) for serialization across server/client boundary
|
||||
const coreNavigation = [
|
||||
{
|
||||
id: 'Dashboard',
|
||||
title: 'Tableau de bord',
|
||||
icon: 'DashboardSquare03Icon',
|
||||
items: [
|
||||
{
|
||||
name: 'Tableau de bord',
|
||||
href: '/admin/dashboard',
|
||||
icon: 'DashboardSquare03Icon',
|
||||
current: pathname === '/admin/dashboard'
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Get module navigation from registry (only works on server)
|
||||
const moduleNavigation = getAllAdminNavigation(pathname);
|
||||
|
||||
// System navigation (always at the end)
|
||||
const systemNavigation = [
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Utilisateurs',
|
||||
icon: 'UserMultiple02Icon',
|
||||
items: [
|
||||
{
|
||||
name: 'Utilisateurs',
|
||||
href: '/admin/users',
|
||||
icon: 'UserMultiple02Icon',
|
||||
current: pathname.startsWith('/admin/users')
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return [...coreNavigation, ...moduleNavigation, ...systemNavigation];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Admin Page - Server Component Wrapper for Next.js App Router
|
||||
*
|
||||
* This is a complete server component that handles all admin routes.
|
||||
* Users can simply re-export this in their app/admin/[...admin]/page.js:
|
||||
*
|
||||
* ```javascript
|
||||
* export { default } from '@hykocx/zen/admin/page';
|
||||
* ```
|
||||
*
|
||||
* This eliminates the need to manually import and pass all actions and props.
|
||||
*/
|
||||
|
||||
import { AdminPagesLayout, AdminPagesClient } from '@hykocx/zen/admin/pages';
|
||||
import { protectAdmin } from '@hykocx/zen/admin';
|
||||
import { buildNavigationSections } from '@hykocx/zen/admin/navigation';
|
||||
import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
|
||||
import { logoutAction } from '@hykocx/zen/auth/actions';
|
||||
import { getAppName, getModulesConfig, getAppConfig, moduleSystem } from '@hykocx/zen';
|
||||
|
||||
const { getAdminPage } = moduleSystem;
|
||||
|
||||
/**
|
||||
* Parse admin route params and build the module path
|
||||
* Handles nested paths like /admin/invoice/clients/edit/123
|
||||
*
|
||||
* @param {Object} params - Next.js route params
|
||||
* @returns {Object} Parsed info with path, action, and id
|
||||
*/
|
||||
function parseAdminRoute(params) {
|
||||
const parts = params?.admin || [];
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { path: '/admin/dashboard', action: null, id: null, isCorePage: true };
|
||||
}
|
||||
|
||||
// Check for core pages first
|
||||
const corePages = ['dashboard', 'users', 'profile'];
|
||||
if (corePages.includes(parts[0])) {
|
||||
// Users: support /admin/users/edit/:id
|
||||
if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) {
|
||||
return { path: '/admin/users', action: 'edit', id: parts[2], isCorePage: true };
|
||||
}
|
||||
return { path: `/admin/${parts[0]}`, action: null, id: null, isCorePage: true };
|
||||
}
|
||||
|
||||
// Build module path
|
||||
// Look for 'new', 'create', or 'edit' to determine action
|
||||
const actionKeywords = ['new', 'create', 'edit'];
|
||||
let pathParts = [];
|
||||
let action = null;
|
||||
let id = null;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (actionKeywords.includes(part)) {
|
||||
action = part === 'create' ? 'new' : part;
|
||||
// If it's 'edit', the next part is the ID
|
||||
if (action === 'edit' && i + 1 < parts.length) {
|
||||
id = parts[i + 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
pathParts.push(part);
|
||||
}
|
||||
|
||||
// Build the full path
|
||||
let fullPath = '/admin/' + pathParts.join('/');
|
||||
if (action) {
|
||||
fullPath += '/' + action;
|
||||
}
|
||||
|
||||
return { path: fullPath, action, id, isCorePage: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a module page
|
||||
* @param {string} fullPath - Full admin path
|
||||
* @returns {Object|null} Module info if it's a module page, null otherwise
|
||||
*/
|
||||
function getModulePageInfo(fullPath) {
|
||||
const modulePage = getAdminPage(fullPath);
|
||||
if (modulePage) {
|
||||
return {
|
||||
module: modulePage.module,
|
||||
path: fullPath
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function AdminPage({ params }) {
|
||||
const resolvedParams = await params;
|
||||
const session = await protectAdmin();
|
||||
const appName = getAppName();
|
||||
const enabledModules = getModulesConfig();
|
||||
const config = getAppConfig();
|
||||
|
||||
const statsResult = await getDashboardStats();
|
||||
const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||
|
||||
// Fetch module dashboard stats for widgets
|
||||
const moduleStats = await getModuleDashboardStats();
|
||||
|
||||
// Build navigation on server where module registry is available
|
||||
const navigationSections = buildNavigationSections('/', enabledModules);
|
||||
|
||||
// Parse route and build path
|
||||
const { path, action, id, isCorePage } = parseAdminRoute(resolvedParams);
|
||||
|
||||
// Check if this is a module page (just check existence, don't load)
|
||||
const modulePageInfo = isCorePage ? null : getModulePageInfo(path);
|
||||
|
||||
return (
|
||||
<AdminPagesLayout
|
||||
user={session.user}
|
||||
onLogout={logoutAction}
|
||||
appName={appName}
|
||||
enabledModules={enabledModules}
|
||||
navigationSections={navigationSections}
|
||||
>
|
||||
<AdminPagesClient
|
||||
params={resolvedParams}
|
||||
user={session.user}
|
||||
dashboardStats={dashboardStats}
|
||||
moduleStats={moduleStats}
|
||||
modulePageInfo={modulePageInfo}
|
||||
routeInfo={{ path, action, id }}
|
||||
enabledModules={enabledModules}
|
||||
/>
|
||||
</AdminPagesLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Pages Export for Next.js App Router
|
||||
*
|
||||
* This exports the admin client components.
|
||||
* Users must create their own server component wrapper that uses protectAdmin.
|
||||
*/
|
||||
|
||||
export { default as AdminPagesClient } from './components/AdminPages.js';
|
||||
export { default as AdminPagesLayout } from './components/AdminPagesLayout.js';
|
||||
@@ -0,0 +1,347 @@
|
||||
# Custom auth pages
|
||||
|
||||
This guide explains how to build your own auth pages (login, register, forgot password, reset password, confirm email, logout) so they match your site’s layout and style. For a basic site you can keep using the [default auth page](#default-auth-page).
|
||||
|
||||
## Overview
|
||||
|
||||
You can use a **custom page for every auth flow**:
|
||||
|
||||
| Page | Component | Server action(s) |
|
||||
|-----------------|-----------------------|-------------------------------------|
|
||||
| Login | `LoginPage` | `loginAction`, `setSessionCookie` |
|
||||
| Register | `RegisterPage` | `registerAction` |
|
||||
| Forgot password | `ForgotPasswordPage` | `forgotPasswordAction` |
|
||||
| Reset password | `ResetPasswordPage` | `resetPasswordAction` |
|
||||
| Confirm email | `ConfirmEmailPage` | `verifyEmailAction` |
|
||||
| Logout | `LogoutPage` | `logoutAction`, `setSessionCookie` |
|
||||
|
||||
- **Components**: from `@hykocx/zen/auth/components`
|
||||
- **Actions**: from `@hykocx/zen/auth/actions`
|
||||
|
||||
Create your own routes (e.g. `/login`, `/register`, `/auth/forgot`) and wrap Zen’s components in your layout. Each page follows the same pattern: a **server component** that loads data and passes actions, and a **client wrapper** that handles navigation and renders the Zen component.
|
||||
|
||||
---
|
||||
|
||||
## Route structure
|
||||
|
||||
Choose a URL scheme and use it consistently. Two common options:
|
||||
|
||||
**Option A – All under `/auth/*` (like the default)**
|
||||
`/auth/login`, `/auth/register`, `/auth/forgot`, `/auth/reset`, `/auth/confirm`, `/auth/logout`
|
||||
|
||||
**Option B – Top-level routes**
|
||||
`/login`, `/register`, `/forgot`, `/reset`, `/confirm`, `/logout`
|
||||
|
||||
The `onNavigate` callback receives one of: `'login' | 'register' | 'forgot' | 'reset'`. Map each to your chosen path, e.g. `router.push(\`/auth/${page}\`)` or `router.push(\`/${page}\`)`.
|
||||
|
||||
Reset and confirm pages need `email` and `token` from the URL (e.g. `/auth/reset?email=...&token=...`). Your server page can read `searchParams` and pass them to the component.
|
||||
|
||||
---
|
||||
|
||||
## Component reference (props)
|
||||
|
||||
Use this when wiring each custom page.
|
||||
|
||||
| Component | Props |
|
||||
|-----------------------|--------|
|
||||
| **LoginPage** | `onSubmit` (loginAction), `onSetSessionCookie`, `onNavigate`, `redirectAfterLogin`, `currentUser` |
|
||||
| **RegisterPage** | `onSubmit` (registerAction), `onNavigate`, `currentUser` |
|
||||
| **ForgotPasswordPage**| `onSubmit` (forgotPasswordAction), `onNavigate`, `currentUser` |
|
||||
| **ResetPasswordPage** | `onSubmit` (resetPasswordAction), `onNavigate`, `email`, `token` (from URL) |
|
||||
| **ConfirmEmailPage** | `onSubmit` (verifyEmailAction), `onNavigate`, `email`, `token` (from URL) |
|
||||
| **LogoutPage** | `onLogout` (logoutAction), `onSetSessionCookie` (optional) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Login
|
||||
|
||||
**Server:** `app/login/page.js` (or `app/auth/login/page.js`)
|
||||
|
||||
```js
|
||||
import { getSession, loginAction, setSessionCookie } from '@hykocx/zen/auth/actions';
|
||||
import { LoginPageWrapper } from './LoginPageWrapper';
|
||||
|
||||
export default async function LoginRoute() {
|
||||
const session = await getSession();
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<LoginPageWrapper
|
||||
loginAction={loginAction}
|
||||
setSessionCookie={setSessionCookie}
|
||||
currentUser={session?.user ?? null}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/login/LoginPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoginPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function LoginPageWrapper({ loginAction, setSessionCookie, currentUser }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<LoginPage
|
||||
onSubmit={loginAction}
|
||||
onSetSessionCookie={setSessionCookie}
|
||||
onNavigate={(page) => router.push(`/auth/${page}`)}
|
||||
redirectAfterLogin="/"
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Register
|
||||
|
||||
**Server:** `app/register/page.js`
|
||||
|
||||
```js
|
||||
import { getSession, registerAction } from '@hykocx/zen/auth/actions';
|
||||
import { RegisterPageWrapper } from './RegisterPageWrapper';
|
||||
|
||||
export default async function RegisterRoute() {
|
||||
const session = await getSession();
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<RegisterPageWrapper
|
||||
registerAction={registerAction}
|
||||
currentUser={session?.user ?? null}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/register/RegisterPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RegisterPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function RegisterPageWrapper({ registerAction, currentUser }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<RegisterPage
|
||||
onSubmit={registerAction}
|
||||
onNavigate={(page) => router.push(`/auth/${page}`)}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Forgot password
|
||||
|
||||
**Server:** `app/forgot/page.js`
|
||||
|
||||
```js
|
||||
import { getSession, forgotPasswordAction } from '@hykocx/zen/auth/actions';
|
||||
import { ForgotPasswordPageWrapper } from './ForgotPasswordPageWrapper';
|
||||
|
||||
export default async function ForgotRoute() {
|
||||
const session = await getSession();
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<ForgotPasswordPageWrapper
|
||||
forgotPasswordAction={forgotPasswordAction}
|
||||
currentUser={session?.user ?? null}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/forgot/ForgotPasswordPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ForgotPasswordPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function ForgotPasswordPageWrapper({ forgotPasswordAction, currentUser }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<ForgotPasswordPage
|
||||
onSubmit={forgotPasswordAction}
|
||||
onNavigate={(page) => router.push(`/auth/${page}`)}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Reset password
|
||||
|
||||
Requires `email` and `token` from the reset link (e.g. `/auth/reset?email=...&token=...`). Read `searchParams` in the server component and pass them to the client.
|
||||
|
||||
**Server:** `app/auth/reset/page.js` (or `app/reset/page.js` with dynamic segment if needed)
|
||||
|
||||
```js
|
||||
import { resetPasswordAction } from '@hykocx/zen/auth/actions';
|
||||
import { ResetPasswordPageWrapper } from './ResetPasswordPageWrapper';
|
||||
|
||||
export default async function ResetRoute({ searchParams }) {
|
||||
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
|
||||
const email = params.email ?? '';
|
||||
const token = params.token ?? '';
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<ResetPasswordPageWrapper
|
||||
resetPasswordAction={resetPasswordAction}
|
||||
email={email}
|
||||
token={token}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/auth/reset/ResetPasswordPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ResetPasswordPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function ResetPasswordPageWrapper({ resetPasswordAction, email, token }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<ResetPasswordPage
|
||||
onSubmit={resetPasswordAction}
|
||||
onNavigate={(page) => router.push(`/auth/${page}`)}
|
||||
email={email}
|
||||
token={token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Confirm email
|
||||
|
||||
Requires `email` and `token` from the verification link (e.g. `/auth/confirm?email=...&token=...`).
|
||||
|
||||
**Server:** `app/auth/confirm/page.js`
|
||||
|
||||
```js
|
||||
import { verifyEmailAction } from '@hykocx/zen/auth/actions';
|
||||
import { ConfirmEmailPageWrapper } from './ConfirmEmailPageWrapper';
|
||||
|
||||
export default async function ConfirmRoute({ searchParams }) {
|
||||
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
|
||||
const email = params.email ?? '';
|
||||
const token = params.token ?? '';
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<ConfirmEmailPageWrapper
|
||||
verifyEmailAction={verifyEmailAction}
|
||||
email={email}
|
||||
token={token}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/auth/confirm/ConfirmEmailPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ConfirmEmailPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function ConfirmEmailPageWrapper({ verifyEmailAction, email, token }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<ConfirmEmailPage
|
||||
onSubmit={verifyEmailAction}
|
||||
onNavigate={(page) => router.push(`/auth/${page}`)}
|
||||
email={email}
|
||||
token={token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Logout
|
||||
|
||||
**Server:** `app/auth/logout/page.js`
|
||||
|
||||
```js
|
||||
import { logoutAction, setSessionCookie } from '@hykocx/zen/auth/actions';
|
||||
import { LogoutPageWrapper } from './LogoutPageWrapper';
|
||||
|
||||
export default function LogoutRoute() {
|
||||
return (
|
||||
<YourSiteLayout>
|
||||
<LogoutPageWrapper
|
||||
logoutAction={logoutAction}
|
||||
setSessionCookie={setSessionCookie}
|
||||
/>
|
||||
</YourSiteLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client:** `app/auth/logout/LogoutPageWrapper.js`
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { LogoutPage } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function LogoutPageWrapper({ logoutAction, setSessionCookie }) {
|
||||
return (
|
||||
<LogoutPage
|
||||
onLogout={logoutAction}
|
||||
onSetSessionCookie={setSessionCookie}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protecting routes
|
||||
|
||||
Use `protect()` from `@hykocx/zen/auth` and set `redirectTo` to your custom login path:
|
||||
|
||||
```js
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
|
||||
export const middleware = protect({ redirectTo: '/login' });
|
||||
```
|
||||
|
||||
So unauthenticated users are sent to your custom login page.
|
||||
|
||||
---
|
||||
|
||||
## Default auth page
|
||||
|
||||
If you don’t need a custom layout, keep using the built-in auth UI. In `app/auth/[...auth]/page.js`:
|
||||
|
||||
```js
|
||||
export { default } from '@hykocx/zen/auth/page';
|
||||
```
|
||||
|
||||
This serves login, register, forgot, reset, confirm, and logout under `/auth/*` with the default styling.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Client dashboard and user features
|
||||
|
||||
This guide explains how to build a **client dashboard** in your Next.js app using Zen auth: protect routes, show the current user (name, avatar), add an account section to edit profile and avatar, and redirect to login when the user is not connected.
|
||||
|
||||
## What is available
|
||||
|
||||
| Need | Solution |
|
||||
|------|----------|
|
||||
| Require login on a page | `protect()` in a **server component** – redirects to login if not authenticated |
|
||||
| Get current user on server | `getSession()` from `@hykocx/zen/auth/actions` |
|
||||
| Check auth without redirect | `checkAuth()` from `@hykocx/zen/auth` |
|
||||
| Require a role | `requireRole(['admin', 'manager'])` from `@hykocx/zen/auth` |
|
||||
| Show user in client (header/nav) | `UserMenu` or `UserAvatar` + `useCurrentUser` from `@hykocx/zen/auth/components` |
|
||||
| Edit account (name + avatar) | `AccountSection` from `@hykocx/zen/auth/components` |
|
||||
| Call user API from client | `GET /zen/api/users/me`, `PUT /zen/api/users/profile`, `POST/DELETE /zen/api/users/profile/picture` (with `credentials: 'include'`) |
|
||||
|
||||
All user APIs are **session-based**: the session cookie is read on the server. No token in client code. Avatar and profile updates are scoped to the current user; the API validates the session on every request.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protect a dashboard page (redirect if not logged in)
|
||||
|
||||
Use `protect()` in a **server component**. If there is no valid session, the user is redirected to the login page.
|
||||
|
||||
```js
|
||||
// app/dashboard/page.js (Server Component)
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
import { DashboardClient } from './DashboardClient';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await protect({ redirectTo: '/auth/login' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<DashboardClient initialUser={session.user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- `redirectTo`: where to send the user if not authenticated (default: `'/auth/login'`).
|
||||
- `protect()` returns the **session** (with `session.user`: `id`, `email`, `name`, `role`, `image`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 2. Display the current user in the layout (name, avatar)
|
||||
|
||||
**Option A – Server: pass user into a client component**
|
||||
|
||||
In your layout or header (server component), get the session and pass `user` to a client component that shows avatar and name:
|
||||
|
||||
```js
|
||||
// app/layout.js or app/dashboard/layout.js
|
||||
import { getSession } from '@hykocx/zen/auth/actions';
|
||||
import { UserMenu } from '@hykocx/zen/auth/components';
|
||||
|
||||
export default async function Layout({ children }) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
{session?.user ? (
|
||||
<UserMenu user={session.user} accountHref="/dashboard/account" logoutHref="/auth/logout" />
|
||||
) : (
|
||||
<a href="/auth/login">Log in</a>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Option B – Client only: fetch user with `useCurrentUser`**
|
||||
|
||||
If you prefer not to pass user from the server, use the hook in a client component. It calls `GET /zen/api/users/me` with the session cookie:
|
||||
|
||||
```js
|
||||
'use client';
|
||||
|
||||
import { UserMenu } from '@hykocx/zen/auth/components';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<UserMenu
|
||||
accountHref="/dashboard/account"
|
||||
logoutHref="/auth/logout"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`UserMenu` with no `user` prop will call `useCurrentUser()` itself and show a loading state until the request finishes. If the user is not logged in, it renders nothing (you can show a “Log in” link elsewhere).
|
||||
|
||||
**Components:**
|
||||
|
||||
- **`UserMenu`** – Avatar + name + dropdown with “My account” and “Log out”. Props: `user` (optional), `accountHref`, `logoutHref`, `className`.
|
||||
- **`UserAvatar`** – Only the avatar (image or initials). Props: `user`, `size` (`'sm' | 'md' | 'lg'`), `className`.
|
||||
- **`useCurrentUser()`** – Returns `{ user, loading, error, refetch }`. Use when you need the current user in a client component without receiving it from the server.
|
||||
|
||||
---
|
||||
|
||||
## 3. Account page (edit profile and avatar)
|
||||
|
||||
Use **`AccountSection`** on a page that is already protected (e.g. `/dashboard/account`). It shows:
|
||||
|
||||
- Profile picture (upload / remove)
|
||||
- Full name (editable)
|
||||
- Email (read-only)
|
||||
- Optional “Account created” date
|
||||
|
||||
**Server page:**
|
||||
|
||||
```js
|
||||
// app/dashboard/account/page.js
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
import { AccountSection } from '@hykocx/zen/auth/components';
|
||||
|
||||
export default async function AccountPage() {
|
||||
const session = await protect({ redirectTo: '/auth/login' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>My account</h1>
|
||||
<AccountSection initialUser={session.user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- **`initialUser`** – Optional. If you pass `session.user`, the section uses it immediately and does not need an extra API call on load.
|
||||
- **`onUpdate`** – Optional callback after profile or avatar update; you can use it to refresh parent state or revalidate.
|
||||
|
||||
`AccountSection` uses:
|
||||
|
||||
- `PUT /zen/api/users/profile` for name
|
||||
- `POST /zen/api/users/profile/picture` for upload
|
||||
- `DELETE /zen/api/users/profile/picture` for remove
|
||||
|
||||
All with `credentials: 'include'` (session cookie). Ensure your app uses **ToastProvider** (from `@hykocx/zen/toast`) if you want toasts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Check if the user is connected (without redirect)
|
||||
|
||||
Use **`checkAuth()`** in a server component when you only need to know whether someone is logged in:
|
||||
|
||||
```js
|
||||
import { checkAuth } from '@hykocx/zen/auth';
|
||||
|
||||
export default async function Page() {
|
||||
const session = await checkAuth();
|
||||
return session ? <div>Hello, {session.user.name}</div> : <div>Please log in</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Use **`requireRole()`** when a page is only for certain roles:
|
||||
|
||||
```js
|
||||
import { requireRole } from '@hykocx/zen/auth';
|
||||
|
||||
export default async function ManagerPage() {
|
||||
const session = await requireRole(['admin', 'manager'], {
|
||||
redirectTo: '/auth/login',
|
||||
forbiddenRedirect: '/dashboard',
|
||||
});
|
||||
return <div>Manager content</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Security summary
|
||||
|
||||
- **Session cookie**: HttpOnly, validated on the server for every protected API call.
|
||||
- **User APIs**:
|
||||
- `GET /zen/api/users/me` – current user only.
|
||||
- `PUT /zen/api/users/profile` – update only the authenticated user’s name.
|
||||
- Profile picture upload/delete – scoped to the current user; storage path includes `users/{userId}/...`.
|
||||
- **Storage**: User files under `users/{userId}/...` are only served if the request session matches that `userId` (or admin).
|
||||
- **Protection**: Use `protect()` or `requireRole()` in server components so unauthenticated or unauthorized users never see sensitive dashboard content.
|
||||
|
||||
---
|
||||
|
||||
## 6. Minimal dashboard example
|
||||
|
||||
```text
|
||||
app/
|
||||
layout.js # Root layout with ToastProvider if you use it
|
||||
auth/
|
||||
[...auth]/page.js # Zen default auth page (login, register, logout, etc.)
|
||||
dashboard/
|
||||
layout.js # Optional: layout that shows UserMenu and requires login
|
||||
page.js # Protected dashboard home
|
||||
account/
|
||||
page.js # Protected account page with AccountSection
|
||||
```
|
||||
|
||||
**dashboard/layout.js:**
|
||||
|
||||
```js
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
import { UserMenu } from '@hykocx/zen/auth/components';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function DashboardLayout({ children }) {
|
||||
const session = await protect({ redirectTo: '/auth/login' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex justify-between items-center p-4 border-b">
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
<UserMenu
|
||||
user={session.user}
|
||||
accountHref="/dashboard/account"
|
||||
logoutHref="/auth/logout"
|
||||
/>
|
||||
</header>
|
||||
<main className="p-4">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**dashboard/page.js:**
|
||||
|
||||
```js
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await protect({ redirectTo: '/auth/login' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {session.user.name}</h1>
|
||||
<p><a href="/dashboard/account">Edit my account</a></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**dashboard/account/page.js:**
|
||||
|
||||
```js
|
||||
import { protect } from '@hykocx/zen/auth';
|
||||
import { AccountSection } from '@hykocx/zen/auth/components';
|
||||
|
||||
export default async function AccountPage() {
|
||||
const session = await protect({ redirectTo: '/auth/login' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>My account</h1>
|
||||
<AccountSection initialUser={session.user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This gives you a protected dashboard, user display in the header, and a dedicated account page to modify profile and avatar, with redirect to login when the user is not connected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Facturation (invoices) section
|
||||
|
||||
If you use the **Invoice** module and want logged-in users to see their own invoices in the dashboard:
|
||||
|
||||
- **User–client link**: In the admin, link a user to a client (User edit → Client). Only invoices for that client are shown.
|
||||
- **API**: `GET /zen/api/invoices/me` (session required) returns the current user’s linked client and that client’s invoices.
|
||||
- **Component**: Use `ClientInvoicesSection` from `@hykocx/zen/invoice/dashboard` on a protected page (e.g. `/dashboard/invoices`).
|
||||
|
||||
See the [Invoice module dashboard guide](../../modules/invoice/README-dashboard.md) for the full setup (API details, page example, linking users to clients, and security).
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Server Actions Export
|
||||
* This file ONLY exports server actions - no client components
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
export {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
getSession,
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setSessionCookie,
|
||||
refreshSessionCookie
|
||||
} from './actions/authActions.js';
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Server Actions for Next.js
|
||||
* Authentication actions for login, register, password reset, etc.
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from '../lib/auth.js';
|
||||
import { validateSession, deleteSession } from '../lib/session.js';
|
||||
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from '../lib/email.js';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js';
|
||||
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js';
|
||||
|
||||
/**
|
||||
* Get the client IP from the current server action context.
|
||||
*/
|
||||
async function getClientIp() {
|
||||
const h = await headers();
|
||||
return getIpFromHeaders(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate anti-bot fields submitted with forms.
|
||||
* - _hp : honeypot field — must be empty
|
||||
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load
|
||||
*
|
||||
* @param {FormData} formData
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateAntiBotFields(formData) {
|
||||
const honeypot = formData.get('_hp');
|
||||
if (honeypot && honeypot.length > 0) {
|
||||
return { valid: false, error: 'Requête invalide' };
|
||||
}
|
||||
|
||||
const t = parseInt(formData.get('_t') || '0', 10);
|
||||
if (t === 0 || Date.now() - t < 1500) {
|
||||
return { valid: false, error: 'Requête invalide' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
export const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {FormData} formData - Form data with email, password, name
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function registerAction(formData) {
|
||||
try {
|
||||
const botCheck = validateAntiBotFields(formData);
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'register');
|
||||
if (!rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
const name = formData.get('name');
|
||||
|
||||
const result = await register({ email, password, name });
|
||||
|
||||
// Send verification email
|
||||
await sendVerificationEmail(result.user.email, result.verificationToken, getPublicBaseUrl());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Compte créé avec succès. Consultez votre e-mail pour vérifier votre compte.',
|
||||
user: result.user
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user
|
||||
* @param {FormData} formData - Form data with email and password
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function loginAction(formData) {
|
||||
try {
|
||||
const botCheck = validateAntiBotFields(formData);
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'login');
|
||||
if (!rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
|
||||
const result = await login({ email, password });
|
||||
|
||||
// Return the token to be set by the client to avoid page refresh
|
||||
// The client will call setSessionCookie after displaying the success message
|
||||
return {
|
||||
success: true,
|
||||
message: 'Connexion réussie',
|
||||
user: result.user,
|
||||
sessionToken: result.session.token
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session cookie (called by client after showing success message)
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function setSessionCookie(token) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh session cookie (extend expiration)
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function refreshSessionCookie(token) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout a user
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function logoutAction() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
await deleteSession(token);
|
||||
}
|
||||
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Déconnexion réussie'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user session
|
||||
* @returns {Promise<Object|null>} Session and user data or null
|
||||
*/
|
||||
export async function getSession() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const result = await validateSession(token);
|
||||
|
||||
// If session was refreshed, also refresh the cookie
|
||||
if (result && result.sessionRefreshed) {
|
||||
await refreshSessionCookie(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Session validation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
* @param {FormData} formData - Form data with email
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function forgotPasswordAction(formData) {
|
||||
try {
|
||||
const botCheck = validateAntiBotFields(formData);
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'forgot_password');
|
||||
if (!rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
|
||||
const result = await requestPasswordReset(email);
|
||||
|
||||
if (result.token) {
|
||||
await sendPasswordResetEmail(email, result.token, getPublicBaseUrl());
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
* @param {FormData} formData - Form data with email, token, and newPassword
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function resetPasswordAction(formData) {
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'reset_password');
|
||||
if (!rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
const token = formData.get('token');
|
||||
const newPassword = formData.get('newPassword');
|
||||
|
||||
// Verify token first
|
||||
const isValid = await verifyResetToken(email, token);
|
||||
if (!isValid) {
|
||||
throw new Error('Jeton de réinitialisation invalide ou expiré');
|
||||
}
|
||||
|
||||
await resetPassword({ email, token, newPassword });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email with token
|
||||
* @param {FormData} formData - Form data with email and token
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function verifyEmailAction(formData) {
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'verify_email');
|
||||
if (!rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
const token = formData.get('token');
|
||||
|
||||
// Verify token
|
||||
const isValid = await verifyEmailToken(email, token);
|
||||
if (!isValid) {
|
||||
throw new Error('Jeton de vérification invalide ou expiré');
|
||||
}
|
||||
|
||||
// Find user and verify
|
||||
const { findOne } = await import('../../../core/database/crud.js');
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
await verifyUserEmail(user.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Reusable "Edit account" section: display name, email (read-only), avatar upload/remove.
|
||||
* Use on a protected dashboard page. Requires session cookie (user must be logged in).
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} [props.initialUser] - Initial user from server (e.g. getSession().user). If omitted, fetches from API.
|
||||
* @param {function} [props.onUpdate] - Called after profile or avatar update with the new user object (e.g. to refresh layout)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card, Input, Button } from '../../../shared/components/index.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
import { useCurrentUser } from './useCurrentUser.js';
|
||||
import UserAvatar from './UserAvatar.js';
|
||||
|
||||
const API_BASE = '/zen/api';
|
||||
|
||||
function getImageUrl(imageKey) {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
}
|
||||
|
||||
export default function AccountSection({ initialUser, onUpdate }) {
|
||||
const toast = useToast();
|
||||
const { user: fetchedUser, loading: fetchLoading, refetch } = useCurrentUser();
|
||||
|
||||
const user = initialUser ?? fetchedUser;
|
||||
const [formData, setFormData] = useState({ name: user?.name ?? '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData((prev) => ({ ...prev, name: user.name ?? '' }));
|
||||
setImagePreview(user.image ? getImageUrl(user.image) : null);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleNameChange = (value) => {
|
||||
setFormData((prev) => ({ ...prev, name: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name?.trim()) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/profile`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: formData.name.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || data.error || 'Échec de la mise à jour du profil');
|
||||
}
|
||||
toast.success('Profil mis à jour avec succès');
|
||||
onUpdate?.(data.user);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Échec de la mise à jour du profil');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({ name: user?.name ?? '' });
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner un fichier image');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("L'image doit faire moins de 5MB");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setImagePreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`${API_BASE}/users/profile/picture`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || data.error || 'Upload failed');
|
||||
}
|
||||
setImagePreview(getImageUrl(data.user?.image));
|
||||
toast.success('Photo de profil mise à jour avec succès');
|
||||
onUpdate?.(data.user);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Upload failed');
|
||||
setImagePreview(user?.image ? getImageUrl(user.image) : null);
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async () => {
|
||||
if (!user?.image) return;
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/profile/picture`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || data.error || 'Remove failed');
|
||||
}
|
||||
setImagePreview(null);
|
||||
toast.success('Photo de profil supprimée avec succès');
|
||||
onUpdate?.(data.user);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Remove failed');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const created_at = user?.created_at ?? user?.createdAt;
|
||||
const hasChanges = formData.name?.trim() !== (user?.name ?? '');
|
||||
|
||||
if (fetchLoading && !initialUser) {
|
||||
return (
|
||||
<Card variant="lightDark">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-24 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="h-32 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card variant="lightDark">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Photo de profil
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="relative">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar user={user} size="lg" className="w-24 h-24" />
|
||||
)}
|
||||
{uploadingImage && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
||||
</Button>
|
||||
{imagePreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="lightDark">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Informations personnelles
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Entrez votre nom complet"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Input
|
||||
label="Courriel"
|
||||
type="email"
|
||||
value={user.email ?? ''}
|
||||
disabled
|
||||
readOnly
|
||||
description="L'email ne peut pas être modifié"
|
||||
/>
|
||||
</div>
|
||||
{created_at && (
|
||||
<Input
|
||||
label="Compte créé"
|
||||
type="text"
|
||||
value={new Date(created_at).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={saving || !hasChanges}
|
||||
loading={saving}
|
||||
>
|
||||
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Auth Pages Component - Catch-all route for Next.js App Router
|
||||
* This component handles all authentication routes: login, register, forgot, reset, confirm
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import LoginPage from './pages/LoginPage.js';
|
||||
import RegisterPage from './pages/RegisterPage.js';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage.js';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage.js';
|
||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.js';
|
||||
import LogoutPage from './pages/LogoutPage.js';
|
||||
|
||||
export default function AuthPagesClient({
|
||||
params,
|
||||
searchParams,
|
||||
registerAction,
|
||||
loginAction,
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
logoutAction,
|
||||
setSessionCookieAction,
|
||||
redirectAfterLogin = '/',
|
||||
currentUser = null
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentPage, setCurrentPage] = useState(null); // null = loading
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Get page from params or URL
|
||||
const getPageFromParams = () => {
|
||||
if (params?.auth?.[0]) {
|
||||
return params.auth[0];
|
||||
}
|
||||
|
||||
// Fallback: read from URL
|
||||
if (typeof window !== 'undefined') {
|
||||
const pathname = window.location.pathname;
|
||||
const match = pathname.match(/\/auth\/([^\/\?]+)/);
|
||||
return match ? match[1] : 'login';
|
||||
}
|
||||
|
||||
return 'login';
|
||||
};
|
||||
|
||||
const page = getPageFromParams();
|
||||
setCurrentPage(page);
|
||||
setIsLoading(false);
|
||||
}, [params]);
|
||||
|
||||
// Extract email and token from searchParams (handles both Promise and regular object)
|
||||
useEffect(() => {
|
||||
const extractSearchParams = async () => {
|
||||
let resolvedParams = searchParams;
|
||||
|
||||
// Check if searchParams is a Promise (Next.js 15+)
|
||||
if (searchParams && typeof searchParams.then === 'function') {
|
||||
resolvedParams = await searchParams;
|
||||
}
|
||||
|
||||
// Extract email and token from URL if not in searchParams
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setEmail(resolvedParams?.email || urlParams.get('email') || '');
|
||||
setToken(resolvedParams?.token || urlParams.get('token') || '');
|
||||
} else {
|
||||
setEmail(resolvedParams?.email || '');
|
||||
setToken(resolvedParams?.token || '');
|
||||
}
|
||||
};
|
||||
|
||||
extractSearchParams();
|
||||
}, [searchParams]);
|
||||
|
||||
const navigate = (page) => {
|
||||
router.push(`/auth/${page}`);
|
||||
};
|
||||
|
||||
// Don't render anything while determining the correct page
|
||||
if (isLoading || !currentPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Page components mapping
|
||||
const pageComponents = {
|
||||
login: () => <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />,
|
||||
register: () => <RegisterPage onSubmit={registerAction} onNavigate={navigate} currentUser={currentUser} />,
|
||||
forgot: () => <ForgotPasswordPage onSubmit={forgotPasswordAction} onNavigate={navigate} currentUser={currentUser} />,
|
||||
reset: () => <ResetPasswordPage onSubmit={resetPasswordAction} onNavigate={navigate} email={email} token={token} />,
|
||||
confirm: () => <ConfirmEmailPage onSubmit={verifyEmailAction} onNavigate={navigate} email={email} token={token} />,
|
||||
logout: () => <LogoutPage onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />
|
||||
};
|
||||
|
||||
// Render the appropriate page
|
||||
const PageComponent = pageComponents[currentPage];
|
||||
return PageComponent ? <PageComponent /> : <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Auth Pages Layout - Server Component
|
||||
* Provides the layout structure for authentication pages
|
||||
*
|
||||
* Usage:
|
||||
* <AuthPagesLayout>
|
||||
* <AuthPagesClient {...props} />
|
||||
* </AuthPagesLayout>
|
||||
*/
|
||||
|
||||
export default function AuthPagesLayout({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
|
||||
<div className="max-w-md w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Displays the current user's avatar (image or initials fallback).
|
||||
* Image is loaded from /zen/api/storage/{key}; session cookie is sent automatically.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.user - User object with optional image (storage key) and name
|
||||
* @param {'sm'|'md'|'lg'} [props.size='md'] - Size of the avatar
|
||||
* @param {string} [props.className] - Additional CSS classes for the wrapper
|
||||
*/
|
||||
|
||||
function getImageUrl(imageKey) {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name || !name.trim()) return '?';
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
};
|
||||
|
||||
export default function UserAvatar({ user, size = 'md', className = '' }) {
|
||||
const sizeClass = sizeClasses[size] || sizeClasses.md;
|
||||
const imageUrl = user?.image ? getImageUrl(user.image) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full overflow-hidden flex items-center justify-center bg-neutral-700 text-white font-medium shrink-0 ${sizeClass} ${className}`}
|
||||
aria-hidden
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={user?.name ? `${user.name} avatar` : 'Avatar'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getInitials(user?.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* User menu: avatar + name with optional dropdown (account link, logout).
|
||||
* Can receive user from server (e.g. from getSession()) or use useCurrentUser() on client.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} [props.user] - User from server (getSession().user). If not provided, useCurrentUser() is used.
|
||||
* @param {string} [props.accountHref='/dashboard/account'] - Link for "My account"
|
||||
* @param {string} [props.logoutHref='/auth/logout'] - Link for logout
|
||||
* @param {string} [props.className] - Extra classes for the menu wrapper
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import UserAvatar from './UserAvatar.js';
|
||||
import { useCurrentUser } from './useCurrentUser.js';
|
||||
|
||||
export default function UserMenu({
|
||||
user: userProp,
|
||||
accountHref = '/dashboard/account',
|
||||
logoutHref = '/auth/logout',
|
||||
className = '',
|
||||
}) {
|
||||
const { user: userFromHook, loading } = useCurrentUser();
|
||||
const user = userProp ?? userFromHook;
|
||||
|
||||
if (loading && !userProp) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-700 animate-pulse" />
|
||||
<div className="h-4 w-24 bg-neutral-700 rounded animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className={`relative ${className}`}>
|
||||
<Menu.Button className="flex items-center gap-2 sm:gap-3 px-2 py-1.5 rounded-lg hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-400 transition-colors">
|
||||
<UserAvatar user={user} size="md" />
|
||||
<span className="text-sm font-medium text-inherit truncate max-w-[120px] sm:max-w-[160px]">
|
||||
{user.name || user.email || 'Account'}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-neutral-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg bg-white dark:bg-neutral-900 shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 focus:outline-none z-50">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate">{user.name || 'User'}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={accountHref}
|
||||
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
|
||||
>
|
||||
My account
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={logoutHref}
|
||||
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
|
||||
>
|
||||
Log out
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Auth Components Export
|
||||
*
|
||||
* Use these components to build custom auth pages for every flow (login, register, forgot,
|
||||
* reset, confirm, logout) so they match your site's style.
|
||||
* For a ready-made catch-all auth UI, use AuthPagesClient from '@hykocx/zen/auth/pages'.
|
||||
* For the default full-page auth (no custom layout), re-export from '@hykocx/zen/auth/page'.
|
||||
*
|
||||
* --- Custom auth pages (all types) ---
|
||||
*
|
||||
* Pattern: server component loads session/searchParams and passes actions to a client wrapper;
|
||||
* client wrapper uses useRouter for onNavigate and renders the Zen component.
|
||||
*
|
||||
* Component props:
|
||||
* - LoginPage: onSubmit (loginAction), onSetSessionCookie, onNavigate, redirectAfterLogin, currentUser
|
||||
* - RegisterPage: onSubmit (registerAction), onNavigate, currentUser
|
||||
* - ForgotPasswordPage: onSubmit (forgotPasswordAction), onNavigate, currentUser
|
||||
* - ResetPasswordPage: onSubmit (resetPasswordAction), onNavigate, email, token (from URL)
|
||||
* - ConfirmEmailPage: onSubmit (verifyEmailAction), onNavigate, email, token (from URL)
|
||||
* - LogoutPage: onLogout (logoutAction), onSetSessionCookie (optional)
|
||||
*
|
||||
* onNavigate receives 'login' | 'register' | 'forgot' | 'reset'. Map to your routes (e.g. /auth/${page}).
|
||||
* For reset/confirm, pass email and token from searchParams. Full guide: see README-custom-login.md in this package.
|
||||
* Protect routes with protect() from '@hykocx/zen/auth', redirectTo your login path.
|
||||
*
|
||||
* --- Dashboard / user display ---
|
||||
*
|
||||
* UserAvatar, UserMenu, AccountSection, useCurrentUser: see README-dashboard.md.
|
||||
*/
|
||||
|
||||
export { default as AuthPagesLayout } from './AuthPagesLayout.js';
|
||||
export { default as AuthPagesClient } from './AuthPages.js';
|
||||
export { default as LoginPage } from './pages/LoginPage.js';
|
||||
export { default as RegisterPage } from './pages/RegisterPage.js';
|
||||
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.js';
|
||||
export { default as ResetPasswordPage } from './pages/ResetPasswordPage.js';
|
||||
export { default as ConfirmEmailPage } from './pages/ConfirmEmailPage.js';
|
||||
export { default as LogoutPage } from './pages/LogoutPage.js';
|
||||
|
||||
export { default as UserAvatar } from './UserAvatar.js';
|
||||
export { default as UserMenu } from './UserMenu.js';
|
||||
export { default as AccountSection } from './AccountSection.js';
|
||||
export { useCurrentUser } from './useCurrentUser.js';
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Confirm Email Page Component
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token }) {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [hasVerified, setHasVerified] = useState(false);
|
||||
const isVerifyingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('ConfirmEmailPage useEffect triggered', { email, token, hasVerified });
|
||||
|
||||
// Check for persisted success message on mount
|
||||
const persistedSuccess = sessionStorage.getItem('emailVerificationSuccess');
|
||||
console.log('Persisted success message:', persistedSuccess);
|
||||
|
||||
if (persistedSuccess) {
|
||||
console.log('Restoring persisted success message');
|
||||
setSuccess(persistedSuccess);
|
||||
setIsLoading(false);
|
||||
setHasVerified(true); // Mark as verified to prevent re-verification
|
||||
// Clear the persisted message after showing it
|
||||
sessionStorage.removeItem('emailVerificationSuccess');
|
||||
// Redirect after showing the message
|
||||
setTimeout(() => {
|
||||
onNavigate('login');
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-verify on mount, but only once
|
||||
if (email && token && !hasVerified && !isVerifyingRef.current) {
|
||||
console.log('Starting email verification');
|
||||
verifyEmail();
|
||||
} else if (!email || !token) {
|
||||
console.log('Invalid email or token');
|
||||
setError('Lien de vérification invalide');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [email, token, hasVerified, onNavigate]);
|
||||
|
||||
async function verifyEmail() {
|
||||
// Prevent multiple calls
|
||||
if (hasVerified || isVerifyingRef.current) {
|
||||
console.log('Email verification already attempted or in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flags IMMEDIATELY to prevent multiple calls
|
||||
isVerifyingRef.current = true;
|
||||
setHasVerified(true);
|
||||
|
||||
// Clear any existing states at the start
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
console.log('Starting email verification for:', email);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('email', email);
|
||||
formData.set('token', token);
|
||||
|
||||
try {
|
||||
const result = await onSubmit(formData);
|
||||
console.log('Verification result:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Verification successful');
|
||||
const successMessage = result.message || 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.';
|
||||
|
||||
// Persist success message in sessionStorage
|
||||
sessionStorage.setItem('emailVerificationSuccess', successMessage);
|
||||
|
||||
setSuccess(successMessage);
|
||||
setIsLoading(false);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
onNavigate('login');
|
||||
}, 3000);
|
||||
} else {
|
||||
console.log('Verification failed:', result.error);
|
||||
setError(result.error || 'Échec de la vérification de l\'e-mail');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Email verification error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
|
||||
|
||||
return (
|
||||
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Vérification de l'e-mail
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Nous vérifions votre adresse e-mail...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center py-10">
|
||||
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white"></div>
|
||||
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Vérification de votre e-mail en cours...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message - Only show if success and no error */}
|
||||
{success && !error && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message - Only show if error and no success */}
|
||||
{error && !success && (
|
||||
<div>
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800/50 text-center">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate('login');
|
||||
}}
|
||||
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
|
||||
>
|
||||
← Retour à la connexion
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redirect message - Only show if success and no error */}
|
||||
{success && !error && (
|
||||
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm mt-3">Redirection vers la connexion...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Forgot Password Page Component
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser = null }) {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
email: ''
|
||||
});
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [formLoadedAt, setFormLoadedAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFormLoadedAt(Date.now());
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
const submitData = new FormData();
|
||||
submitData.append('email', formData.email);
|
||||
submitData.append('_hp', honeypot);
|
||||
submitData.append('_t', String(formLoadedAt));
|
||||
|
||||
try {
|
||||
const result = await onSubmit(submitData);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setError(result.error || 'Échec de l\'envoi de l\'e-mail de réinitialisation');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Forgot password error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
|
||||
|
||||
return (
|
||||
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Mot de passe oublié
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Already Connected Message */}
|
||||
{currentUser && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-blue-700 dark:text-blue-400">
|
||||
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
|
||||
>
|
||||
Se déconnecter ?
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Form */}
|
||||
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* Honeypot — invisible to humans, filled by bots */}
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
|
||||
<label htmlFor="_hp_forgot">Website</label>
|
||||
<input id="_hp_forgot" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || success || currentUser}
|
||||
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
|
||||
<span>Envoi en cours...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Envoyer le lien de réinitialisation'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) {
|
||||
onNavigate('login');
|
||||
}
|
||||
}}
|
||||
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
|
||||
>
|
||||
← Retour à la connexion
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Login Page Component
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, redirectAfterLogin = '/', currentUser = null }) {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [formLoadedAt, setFormLoadedAt] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setFormLoadedAt(Date.now());
|
||||
}, []);
|
||||
|
||||
// If already logged in, redirect to redirectAfterLogin
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
router.replace(redirectAfterLogin);
|
||||
}
|
||||
}, [currentUser, redirectAfterLogin, router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !isLoading && !success) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
const submitData = new FormData();
|
||||
submitData.append('email', formData.email);
|
||||
submitData.append('password', formData.password);
|
||||
submitData.append('_hp', honeypot);
|
||||
submitData.append('_t', String(formLoadedAt));
|
||||
|
||||
try {
|
||||
const result = await onSubmit(submitData);
|
||||
|
||||
if (result.success) {
|
||||
const successMsg = result.message || 'Connexion réussie ! Redirection...';
|
||||
|
||||
// Display success message immediately (no page refresh because we didn't set cookie yet)
|
||||
setSuccess(successMsg);
|
||||
setIsLoading(false);
|
||||
|
||||
// Wait for user to see the success message
|
||||
setTimeout(async () => {
|
||||
// Now set the session cookie (this might cause a refresh, but we're redirecting anyway)
|
||||
if (result.sessionToken && onSetSessionCookie) {
|
||||
await onSetSessionCookie(result.sessionToken);
|
||||
}
|
||||
// Then navigate
|
||||
router.push(redirectAfterLogin);
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(result.error || 'Échec de la connexion');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
|
||||
|
||||
return (
|
||||
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Connexion
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Veuillez vous connecter pour continuer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Already logged in: redirecting (brief message while redirect runs) */}
|
||||
{currentUser && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-blue-400/50 border-t-blue-600 rounded-full animate-spin flex-shrink-0 dark:border-blue-500/30 dark:border-t-blue-500"></div>
|
||||
<span className="text-xs text-blue-700 dark:text-blue-400">Redirection...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<div className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* Honeypot — invisible to humans, filled by bots */}
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
|
||||
<label htmlFor="_hp_login">Website</label>
|
||||
<input id="_hp_login" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white">
|
||||
Mot de passe
|
||||
</label>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) {
|
||||
onNavigate('forgot');
|
||||
}
|
||||
}}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-900 transition-colors duration-200 dark:text-neutral-300 dark:hover:text-white"
|
||||
>
|
||||
Mot de passe oublié ?
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || success || currentUser}
|
||||
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
|
||||
<span>Connexion en cours...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) {
|
||||
onNavigate('register');
|
||||
}
|
||||
}}
|
||||
className="group flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">Pas de compte ? </span>
|
||||
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">S'inscrire</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Logout Page Component
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LogoutPage({ onLogout, onSetSessionCookie }) {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Call the logout action if provided
|
||||
if (onLogout) {
|
||||
const result = await onLogout();
|
||||
|
||||
if (result && !result.success) {
|
||||
setError(result.error || 'Échec de la déconnexion');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session cookie if provided
|
||||
if (onSetSessionCookie) {
|
||||
await onSetSessionCookie('', { expires: new Date(0) });
|
||||
}
|
||||
|
||||
// Show success message
|
||||
setSuccess('Vous avez été déconnecté. Redirection...');
|
||||
setIsLoading(false);
|
||||
|
||||
// Wait for user to see the success message, then redirect
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 100);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
setError('Une erreur inattendue s\'est produite lors de la déconnexion');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Prêt à vous déconnecter ?
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Cela mettra fin à votre session et vous déconnectera de votre compte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading || success}
|
||||
className="cursor-pointer w-full bg-red-600 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-red-500/20 dark:focus:ring-red-400/30"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Déconnexion en cours...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Se déconnecter'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez changé d'avis ? </span>
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
|
||||
>
|
||||
Retour
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Register Page Component
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PasswordStrengthIndicator } from '../../../../shared/components';
|
||||
|
||||
export default function RegisterPage({ onSubmit, onNavigate, currentUser = null }) {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [formLoadedAt, setFormLoadedAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFormLoadedAt(Date.now());
|
||||
}, []);
|
||||
|
||||
// Validation functions
|
||||
const validateEmail = (email) => {
|
||||
const errors = [];
|
||||
|
||||
if (email.length > 254) {
|
||||
errors.push('L\'e-mail doit contenir 254 caractères ou moins');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validatePassword = (password) => {
|
||||
const errors = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins une majuscule');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins une minuscule');
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins un chiffre');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateName = (name) => {
|
||||
const errors = [];
|
||||
|
||||
if (name.trim().length === 0) {
|
||||
errors.push('Le nom ne peut pas être vide');
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
errors.push('Le nom doit contenir 100 caractères ou moins');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
const emailErrors = validateEmail(formData.email);
|
||||
const passwordErrors = validatePassword(formData.password);
|
||||
const nameErrors = validateName(formData.name);
|
||||
|
||||
return emailErrors.length === 0 &&
|
||||
passwordErrors.length === 0 &&
|
||||
nameErrors.length === 0 &&
|
||||
formData.password === formData.confirmPassword &&
|
||||
formData.email.trim().length > 0;
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
// Frontend validation
|
||||
const emailErrors = validateEmail(formData.email);
|
||||
const passwordErrors = validatePassword(formData.password);
|
||||
const nameErrors = validateName(formData.name);
|
||||
|
||||
if (emailErrors.length > 0) {
|
||||
setError(emailErrors[0]); // Show first error
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordErrors.length > 0) {
|
||||
setError(passwordErrors[0]); // Show first error
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameErrors.length > 0) {
|
||||
setError(nameErrors[0]); // Show first error
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password match
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = new FormData();
|
||||
submitData.append('name', formData.name);
|
||||
submitData.append('email', formData.email);
|
||||
submitData.append('password', formData.password);
|
||||
submitData.append('confirmPassword', formData.confirmPassword);
|
||||
submitData.append('_hp', honeypot);
|
||||
submitData.append('_t', String(formLoadedAt));
|
||||
|
||||
try {
|
||||
const result = await onSubmit(submitData);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setError(result.error || 'Échec de l\'inscription');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
|
||||
|
||||
return (
|
||||
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Créer un compte
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Inscrivez-vous pour commencer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Already Connected Message */}
|
||||
{currentUser && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-blue-700 dark:text-blue-400">
|
||||
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
|
||||
>
|
||||
Se déconnecter ?
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Registration Form */}
|
||||
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* Honeypot — invisible to humans, filled by bots */}
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
|
||||
<label htmlFor="_hp_register">Website</label>
|
||||
<input id="_hp_register" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
Nom complet
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
maxLength="100"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="John Doe"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
maxLength="254"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthIndicator password={formData.password} showRequirements={true} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
disabled={success || currentUser}
|
||||
className={inputClasses}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || success || currentUser || !isFormValid()}
|
||||
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
|
||||
<span>Création du compte en cours...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Créer un compte'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) {
|
||||
onNavigate('login');
|
||||
}
|
||||
}}
|
||||
className="group flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez déjà un compte ? </span>
|
||||
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">Se connecter</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Reset Password Page Component
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PasswordStrengthIndicator } from '../../../../shared/components';
|
||||
|
||||
export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }) {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
// Validation functions
|
||||
const validatePassword = (password) => {
|
||||
const errors = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins une majuscule');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins une minuscule');
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('Le mot de passe doit contenir au moins un chiffre');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
const passwordErrors = validatePassword(formData.newPassword);
|
||||
|
||||
return passwordErrors.length === 0 &&
|
||||
formData.newPassword === formData.confirmPassword &&
|
||||
formData.newPassword.length > 0;
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
// Frontend validation
|
||||
const passwordErrors = validatePassword(formData.newPassword);
|
||||
|
||||
if (passwordErrors.length > 0) {
|
||||
setError(passwordErrors[0]); // Show first error
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password match
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = new FormData();
|
||||
submitData.append('newPassword', formData.newPassword);
|
||||
submitData.append('confirmPassword', formData.confirmPassword);
|
||||
submitData.append('email', email);
|
||||
submitData.append('token', token);
|
||||
|
||||
try {
|
||||
const result = await onSubmit(submitData);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
setIsLoading(false);
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
onNavigate('login');
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(result.error || 'Échec de la réinitialisation du mot de passe');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
|
||||
|
||||
return (
|
||||
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Réinitialiser le mot de passe
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Saisissez votre nouveau mot de passe ci-dessous.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
disabled={success}
|
||||
className={inputClasses}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
disabled={success}
|
||||
className={inputClasses}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || success || !isFormValid()}
|
||||
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
|
||||
<span>Réinitialisation...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Réinitialiser le mot de passe'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate('login');
|
||||
}}
|
||||
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
|
||||
>
|
||||
← Retour à la connexion
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Client hook to fetch the current user from the API.
|
||||
* Uses session cookie (credentials: 'include'); safe to use in client components.
|
||||
*
|
||||
* @returns {{ user: Object|null, loading: boolean, error: string|null, refetch: function }}
|
||||
*
|
||||
* @example
|
||||
* const { user, loading, error, refetch } = useCurrentUser();
|
||||
* if (loading) return <Spinner />;
|
||||
* if (error) return <div>Error: {error}</div>;
|
||||
* if (!user) return <Link href="/auth/login">Log in</Link>;
|
||||
* return <span>Hello, {user.name}</span>;
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_BASE = '/zen/api';
|
||||
|
||||
export function useCurrentUser() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
setError(data.message || data.error || 'Failed to load user');
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useCurrentUser]', err);
|
||||
setError(err.message || 'Failed to load user');
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
return { user, loading, error, refetch: fetchUser };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Zen Authentication Module - Server-side utilities
|
||||
*
|
||||
* For client components, use '@hykocx/zen/auth/pages'
|
||||
* For server actions, use '@hykocx/zen/auth/actions'
|
||||
*/
|
||||
|
||||
// Authentication library (server-side only)
|
||||
export {
|
||||
register,
|
||||
login,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyUserEmail,
|
||||
updateUser
|
||||
} from './lib/auth.js';
|
||||
|
||||
// Session management (server-side only)
|
||||
export {
|
||||
createSession,
|
||||
validateSession,
|
||||
deleteSession,
|
||||
deleteUserSessions,
|
||||
refreshSession
|
||||
} from './lib/session.js';
|
||||
|
||||
// Email utilities (server-side only)
|
||||
export {
|
||||
createEmailVerification,
|
||||
verifyEmailToken,
|
||||
createPasswordReset,
|
||||
verifyResetToken,
|
||||
deleteResetToken,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendPasswordChangedEmail
|
||||
} from './lib/email.js';
|
||||
|
||||
// Password utilities (server-side only)
|
||||
export {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
generateId
|
||||
} from './lib/password.js';
|
||||
|
||||
// Middleware (server-side only)
|
||||
export {
|
||||
protect,
|
||||
checkAuth,
|
||||
requireRole
|
||||
} from './middleware/protect.js';
|
||||
|
||||
// Server Actions (server-side only)
|
||||
export {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
getSession,
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setSessionCookie,
|
||||
refreshSessionCookie
|
||||
} from './actions/authActions.js';
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Authentication Logic
|
||||
* Main authentication functions for user registration, login, and password management
|
||||
*/
|
||||
|
||||
import { create, findOne, updateById, count } from '../../../core/database/crud.js';
|
||||
import { hashPassword, verifyPassword, generateId } from './password.js';
|
||||
import { createSession } from './session.js';
|
||||
import { createEmailVerification, createPasswordReset, deleteResetToken, sendPasswordChangedEmail } from './email.js';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - User password
|
||||
* @param {string} userData.name - User name
|
||||
* @returns {Promise<Object>} Created user and session
|
||||
*/
|
||||
async function register(userData) {
|
||||
const { email, password, name } = userData;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !name) {
|
||||
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
|
||||
}
|
||||
|
||||
// Validate email length (maximum 254 characters - RFC standard)
|
||||
if (email.length > 254) {
|
||||
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password length (minimum 8, maximum 128 characters)
|
||||
if (password.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
|
||||
if (!hasUppercase || !hasLowercase || !hasNumber) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
// Validate name length (maximum 100 characters)
|
||||
if (name.length > 100) {
|
||||
throw new Error('Le nom doit contenir 100 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate name is not empty after trimming
|
||||
if (name.trim().length === 0) {
|
||||
throw new Error('Le nom ne peut pas être vide');
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await findOne('zen_auth_users', { email });
|
||||
if (existingUser) {
|
||||
throw new Error('Un utilisateur avec cet e-mail existe déjà');
|
||||
}
|
||||
|
||||
// Check if this is the first user - if so, make them admin
|
||||
const userCount = await count('zen_auth_users');
|
||||
const role = userCount === 0 ? 'admin' : 'user';
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const userId = generateId();
|
||||
const user = await create('zen_auth_users', {
|
||||
id: userId,
|
||||
email,
|
||||
name,
|
||||
email_verified: false,
|
||||
image: null,
|
||||
role,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Create account with password
|
||||
const accountId = generateId();
|
||||
await create('zen_auth_accounts', {
|
||||
id: accountId,
|
||||
account_id: email,
|
||||
provider_id: 'credential',
|
||||
user_id: user.id,
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Create email verification token
|
||||
const verification = await createEmailVerification(email);
|
||||
|
||||
return {
|
||||
user,
|
||||
verificationToken: verification.token
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @param {string} credentials.email - User email
|
||||
* @param {string} credentials.password - User password
|
||||
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
|
||||
* @returns {Promise<Object>} User and session
|
||||
*/
|
||||
async function login(credentials, sessionOptions = {}) {
|
||||
const { email, password } = credentials;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password) {
|
||||
throw new Error('L\'e-mail et le mot de passe sont requis');
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Find account with password
|
||||
const account = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
});
|
||||
|
||||
if (!account || !account.password) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, account.password);
|
||||
if (!isValid) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await createSession(user.id, sessionOptions);
|
||||
|
||||
return {
|
||||
user,
|
||||
session
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a password reset
|
||||
* @param {string} email - User email
|
||||
* @returns {Promise<Object>} Reset token
|
||||
*/
|
||||
async function requestPasswordReset(email) {
|
||||
// Validate email
|
||||
if (!email) {
|
||||
throw new Error('L\'e-mail est requis');
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
// Don't reveal if user exists or not
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
const reset = await createPasswordReset(email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: reset.token
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
* @param {Object} resetData - Reset data
|
||||
* @param {string} resetData.email - User email
|
||||
* @param {string} resetData.token - Reset token
|
||||
* @param {string} resetData.newPassword - New password
|
||||
* @returns {Promise<Object>} Success status
|
||||
*/
|
||||
async function resetPassword(resetData) {
|
||||
const { email, token, newPassword } = resetData;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !token || !newPassword) {
|
||||
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
|
||||
}
|
||||
|
||||
// Validate password length (minimum 8, maximum 128 characters)
|
||||
if (newPassword.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (newPassword.length > 128) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /\d/.test(newPassword);
|
||||
|
||||
if (!hasUppercase || !hasLowercase || !hasNumber) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
// Verify token is handled in the email module
|
||||
// For now, we'll assume token is valid if it exists in the database
|
||||
|
||||
// Find user
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('Jeton de réinitialisation invalide');
|
||||
}
|
||||
|
||||
// Find account
|
||||
const account = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Compte introuvable');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
// Update password
|
||||
await updateById('zen_auth_accounts', account.id, {
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Delete reset token
|
||||
await deleteResetToken(email);
|
||||
|
||||
// Send password changed confirmation email
|
||||
try {
|
||||
await sendPasswordChangedEmail(email);
|
||||
} catch (error) {
|
||||
// Log error but don't fail the password reset process
|
||||
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, error.message);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user email
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Object>} Updated user
|
||||
*/
|
||||
async function verifyUserEmail(userId) {
|
||||
return await updateById('zen_auth_users', userId, {
|
||||
email_verified: true,
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} updateData - Data to update
|
||||
* @returns {Promise<Object>} Updated user
|
||||
*/
|
||||
async function updateUser(userId, updateData) {
|
||||
const allowedFields = ['name', 'image', 'language'];
|
||||
const filteredData = {};
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (updateData[field] !== undefined) {
|
||||
filteredData[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
filteredData.updated_at = new Date();
|
||||
|
||||
return await updateById('zen_auth_users', userId, filteredData);
|
||||
}
|
||||
|
||||
export {
|
||||
register,
|
||||
login,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyUserEmail,
|
||||
updateUser
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Email Verification and Password Reset
|
||||
* Handles email verification tokens and password reset tokens
|
||||
*/
|
||||
|
||||
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
import { sendAuthEmail } from '../../../core/email/index.js';
|
||||
import { renderVerificationEmail, renderPasswordResetEmail, renderPasswordChangedEmail } from '../../../core/email/templates/index.js';
|
||||
|
||||
/**
|
||||
* Create an email verification token
|
||||
* @param {string} email - User email
|
||||
* @returns {Promise<Object>} Verification object with token
|
||||
*/
|
||||
async function createEmailVerification(email) {
|
||||
const token = generateToken(32);
|
||||
const verificationId = generateId();
|
||||
|
||||
// Token expires in 24 hours
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
// Delete any existing verification tokens for this email
|
||||
await deleteWhere('zen_auth_verifications', {
|
||||
identifier: 'email_verification',
|
||||
value: email
|
||||
});
|
||||
|
||||
const verification = await create('zen_auth_verifications', {
|
||||
id: verificationId,
|
||||
identifier: 'email_verification',
|
||||
value: email,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
return {
|
||||
...verification,
|
||||
token
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an email token
|
||||
* @param {string} email - User email
|
||||
* @param {string} token - Verification token
|
||||
* @returns {Promise<boolean>} True if valid, false otherwise
|
||||
*/
|
||||
async function verifyEmailToken(email, token) {
|
||||
const verification = await findOne('zen_auth_verifications', {
|
||||
identifier: 'email_verification',
|
||||
value: email
|
||||
});
|
||||
|
||||
if (!verification) return false;
|
||||
|
||||
// Verify token matches
|
||||
if (verification.token !== token) return false;
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date(verification.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the verification token after use
|
||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a password reset token
|
||||
* @param {string} email - User email
|
||||
* @returns {Promise<Object>} Reset object with token
|
||||
*/
|
||||
async function createPasswordReset(email) {
|
||||
const token = generateToken(32);
|
||||
const resetId = generateId();
|
||||
|
||||
// Token expires in 1 hour
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 1);
|
||||
|
||||
// Delete any existing reset tokens for this email
|
||||
await deleteWhere('zen_auth_verifications', {
|
||||
identifier: 'password_reset',
|
||||
value: email
|
||||
});
|
||||
|
||||
const reset = await create('zen_auth_verifications', {
|
||||
id: resetId,
|
||||
identifier: 'password_reset',
|
||||
value: email,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
return {
|
||||
...reset,
|
||||
token
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password reset token
|
||||
* @param {string} email - User email
|
||||
* @param {string} token - Reset token
|
||||
* @returns {Promise<boolean>} True if valid, false otherwise
|
||||
*/
|
||||
async function verifyResetToken(email, token) {
|
||||
const reset = await findOne('zen_auth_verifications', {
|
||||
identifier: 'password_reset',
|
||||
value: email
|
||||
});
|
||||
|
||||
if (!reset) return false;
|
||||
|
||||
// Verify token matches
|
||||
if (reset.token !== token) return false;
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date(reset.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: reset.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a password reset token
|
||||
* @param {string} email - User email
|
||||
* @returns {Promise<number>} Number of deleted tokens
|
||||
*/
|
||||
async function deleteResetToken(email) {
|
||||
return await deleteWhere('zen_auth_verifications', {
|
||||
identifier: 'password_reset',
|
||||
value: email
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email using Resend
|
||||
* @param {string} email - User email
|
||||
* @param {string} token - Verification token
|
||||
* @param {string} baseUrl - Base URL of the application
|
||||
*/
|
||||
async function sendVerificationEmail(email, token, baseUrl) {
|
||||
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
|
||||
const html = await renderVerificationEmail(verificationUrl, email, appName);
|
||||
|
||||
const result = await sendAuthEmail({
|
||||
to: email,
|
||||
subject: `Confirmez votre adresse courriel – ${appName}`,
|
||||
html
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[ZEN AUTH] Failed to send verification email to ${email}:`, result.error);
|
||||
throw new Error('Failed to send verification email');
|
||||
}
|
||||
|
||||
console.log(`[ZEN AUTH] Verification email sent to ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email using Resend
|
||||
* @param {string} email - User email
|
||||
* @param {string} token - Reset token
|
||||
* @param {string} baseUrl - Base URL of the application
|
||||
*/
|
||||
async function sendPasswordResetEmail(email, token, baseUrl) {
|
||||
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
|
||||
const html = await renderPasswordResetEmail(resetUrl, email, appName);
|
||||
|
||||
const result = await sendAuthEmail({
|
||||
to: email,
|
||||
subject: `Réinitialisation du mot de passe – ${appName}`,
|
||||
html
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[ZEN AUTH] Failed to send password reset email to ${email}:`, result.error);
|
||||
throw new Error('Failed to send password reset email');
|
||||
}
|
||||
|
||||
console.log(`[ZEN AUTH] Password reset email sent to ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password changed confirmation email using Resend
|
||||
* @param {string} email - User email
|
||||
*/
|
||||
async function sendPasswordChangedEmail(email) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
|
||||
const html = await renderPasswordChangedEmail(email, appName);
|
||||
|
||||
const result = await sendAuthEmail({
|
||||
to: email,
|
||||
subject: `Mot de passe modifié – ${appName}`,
|
||||
html
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, result.error);
|
||||
throw new Error('Failed to send password changed email');
|
||||
}
|
||||
|
||||
console.log(`[ZEN AUTH] Password changed email sent to ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export {
|
||||
createEmailVerification,
|
||||
verifyEmailToken,
|
||||
createPasswordReset,
|
||||
verifyResetToken,
|
||||
deleteResetToken,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendPasswordChangedEmail
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Password Hashing and Verification
|
||||
* Provides secure password hashing using bcrypt
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a password using scrypt (Node.js native)
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate a salt
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Hash password with salt using scrypt
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(salt + ':' + derivedKey.toString('hex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* @param {string} password - Plain text password
|
||||
* @param {string} hash - Hashed password
|
||||
* @returns {Promise<boolean>} True if password matches, false otherwise
|
||||
*/
|
||||
async function verifyPassword(password, hash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, key] = hash.split(':');
|
||||
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(key === derivedKey.toString('hex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random token
|
||||
* @param {number} length - Token length in bytes (default: 32)
|
||||
* @returns {string} Random token
|
||||
*/
|
||||
function generateToken(length = 32) {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ID
|
||||
* @returns {string} Random ID
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
generateId
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* In-memory rate limiter
|
||||
* Stores counters in a Map — resets on server restart, no DB required.
|
||||
*/
|
||||
|
||||
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
|
||||
const store = new Map();
|
||||
|
||||
// Purge expired entries every 10 minutes to avoid memory leak
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store.entries()) {
|
||||
const windowExpired = now > entry.windowStart + entry.windowMs;
|
||||
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
|
||||
if (windowExpired && blockExpired) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Allow garbage collection in test/serverless environments
|
||||
if (cleanup.unref) cleanup.unref();
|
||||
|
||||
/**
|
||||
* Rate limit presets per action.
|
||||
* maxAttempts : number of requests allowed in the window
|
||||
* windowMs : rolling window duration
|
||||
* blockMs : how long to block once the limit is exceeded
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a given identifier is allowed for an action, and record the attempt.
|
||||
*
|
||||
* @param {string} identifier - IP address or user ID
|
||||
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
|
||||
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
|
||||
*/
|
||||
export function checkRateLimit(identifier, action) {
|
||||
const config = RATE_LIMITS[action];
|
||||
if (!config) return { allowed: true };
|
||||
|
||||
const key = `${action}:${identifier}`;
|
||||
const now = Date.now();
|
||||
let entry = store.get(key);
|
||||
|
||||
// Still blocked
|
||||
if (entry?.blockedUntil && now < entry.blockedUntil) {
|
||||
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
|
||||
}
|
||||
|
||||
// Start a fresh window (first request, or previous window has expired)
|
||||
if (!entry || now > entry.windowStart + entry.windowMs) {
|
||||
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
|
||||
return { allowed: true, remaining: config.maxAttempts - 1 };
|
||||
}
|
||||
|
||||
// Increment counter in the current window
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > config.maxAttempts) {
|
||||
entry.blockedUntil = now + config.blockMs;
|
||||
store.set(key, entry);
|
||||
return { allowed: false, retryAfterMs: config.blockMs };
|
||||
}
|
||||
|
||||
store.set(key, entry);
|
||||
return { allowed: true, remaining: config.maxAttempts - entry.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the best-effort client IP from Next.js headers() (server actions).
|
||||
* @param {import('next/headers').ReadonlyHeaders} headersList
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromHeaders(headersList) {
|
||||
return (
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
headersList.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the best-effort client IP from a Next.js Request object (API routes).
|
||||
* @param {Request} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromRequest(request) {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a block duration in human-readable French.
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRetryAfter(ms) {
|
||||
const seconds = Math.ceil(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} secondes`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
return `${hours} heure${hours > 1 ? 's' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Session Management
|
||||
* Handles user session creation, validation, and deletion
|
||||
*/
|
||||
|
||||
import { create, findOne, deleteWhere, updateById } from '../../../core/database/crud.js';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} options - Session options (ipAddress, userAgent)
|
||||
* @returns {Promise<Object>} Session object with token
|
||||
*/
|
||||
async function createSession(userId, options = {}) {
|
||||
const { ipAddress, userAgent } = options;
|
||||
|
||||
// Generate session token
|
||||
const token = generateToken(32);
|
||||
const sessionId = generateId();
|
||||
|
||||
// Session expires in 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const session = await create('zen_auth_sessions', {
|
||||
id: sessionId,
|
||||
user_id: userId,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
ip_address: ipAddress || null,
|
||||
user_agent: userAgent || null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object|null>} Session object with user data or null if invalid
|
||||
*/
|
||||
async function validateSession(token) {
|
||||
if (!token) return null;
|
||||
|
||||
const session = await findOne('zen_auth_sessions', { token });
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
if (new Date(session.expires_at) < new Date()) {
|
||||
await deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user data
|
||||
const user = await findOne('zen_auth_users', { id: session.user_id });
|
||||
|
||||
if (!user) {
|
||||
await deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-refresh session if it expires in less than 20 days
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(session.expires_at);
|
||||
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let sessionRefreshed = false;
|
||||
|
||||
if (daysUntilExpiry < 20) {
|
||||
// Extend session to 30 days from now
|
||||
const newExpiresAt = new Date();
|
||||
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
|
||||
|
||||
await updateById('zen_auth_sessions', session.id, {
|
||||
expires_at: newExpiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Update the session object with new expiration
|
||||
session.expires_at = newExpiresAt;
|
||||
sessionRefreshed = true;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
sessionRefreshed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<number>} Number of deleted sessions
|
||||
*/
|
||||
async function deleteSession(token) {
|
||||
return await deleteWhere('zen_auth_sessions', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all sessions for a user
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of deleted sessions
|
||||
*/
|
||||
async function deleteUserSessions(userId) {
|
||||
return await deleteWhere('zen_auth_sessions', { user_id: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a session (extend expiration)
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object|null>} Updated session or null
|
||||
*/
|
||||
async function refreshSession(token) {
|
||||
const session = await findOne('zen_auth_sessions', { token });
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Extend session by 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
return await updateById('zen_auth_sessions', session.id, {
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createSession,
|
||||
validateSession,
|
||||
deleteSession,
|
||||
deleteUserSessions,
|
||||
refreshSession
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Route Protection Middleware
|
||||
* Utilities to protect routes and check authentication
|
||||
*/
|
||||
|
||||
import { getSession } from '../actions/authActions.js';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Protect a page - requires authentication
|
||||
* Use this in server components to require authentication
|
||||
*
|
||||
* @param {Object} options - Protection options
|
||||
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
|
||||
* @returns {Promise<Object>} Session object with user data
|
||||
*
|
||||
* @example
|
||||
* // In a server component:
|
||||
* import { protect } from '@hykocx/zen/auth';
|
||||
*
|
||||
* export default async function ProtectedPage() {
|
||||
* const session = await protect();
|
||||
* return <div>Welcome, {session.user.name}!</div>;
|
||||
* }
|
||||
*/
|
||||
async function protect(options = {}) {
|
||||
const { redirectTo = '/auth/login' } = options;
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(redirectTo);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* Use this when you want to check authentication without forcing a redirect
|
||||
*
|
||||
* @returns {Promise<Object|null>} Session object or null if not authenticated
|
||||
*
|
||||
* @example
|
||||
* import { checkAuth } from '@hykocx/zen/auth';
|
||||
*
|
||||
* export default async function Page() {
|
||||
* const session = await checkAuth();
|
||||
* return session ? <div>Logged in</div> : <div>Not logged in</div>;
|
||||
* }
|
||||
*/
|
||||
async function checkAuth() {
|
||||
return await getSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific role
|
||||
* @param {Array<string>} allowedRoles - Array of allowed roles
|
||||
* @param {Object} options - Options
|
||||
* @returns {Promise<Object>} Session object
|
||||
*/
|
||||
async function requireRole(allowedRoles = [], options = {}) {
|
||||
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(redirectTo);
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(session.user.role)) {
|
||||
redirect(forbiddenRedirect);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export {
|
||||
protect,
|
||||
checkAuth,
|
||||
requireRole
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Auth Page - Server Component Wrapper for Next.js App Router
|
||||
*
|
||||
* Default auth UI: login, register, forgot, reset, confirm, logout at /auth/[...auth].
|
||||
* Re-export in your app: export { default } from '@hykocx/zen/auth/page';
|
||||
*
|
||||
* For custom auth pages (all flows) that match your site style, use components from
|
||||
* '@hykocx/zen/auth/components' and actions from '@hykocx/zen/auth/actions'.
|
||||
* See README-custom-login.md in this package. Basic sites can keep using this default page.
|
||||
*/
|
||||
|
||||
import { AuthPagesClient } from '@hykocx/zen/auth/pages';
|
||||
import {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setSessionCookie,
|
||||
getSession
|
||||
} from '@hykocx/zen/auth/actions';
|
||||
|
||||
export default async function AuthPage({ params, searchParams }) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
|
||||
<div className="max-w-md w-full">
|
||||
<AuthPagesClient
|
||||
params={params}
|
||||
searchParams={searchParams}
|
||||
registerAction={registerAction}
|
||||
loginAction={loginAction}
|
||||
logoutAction={logoutAction}
|
||||
forgotPasswordAction={forgotPasswordAction}
|
||||
resetPasswordAction={resetPasswordAction}
|
||||
verifyEmailAction={verifyEmailAction}
|
||||
setSessionCookieAction={setSessionCookie}
|
||||
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
|
||||
currentUser={session?.user || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Auth Pages Export for Next.js App Router
|
||||
*
|
||||
* This exports the auth client components.
|
||||
* Users must create their own server component wrapper that imports the actions.
|
||||
*/
|
||||
|
||||
export { default as AuthPagesClient } from './components/AuthPages.js';
|
||||
export { default as AuthPagesLayout } from './components/AuthPagesLayout.js';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
|
||||
|
||||
export function ZenProvider({ children }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
|
||||
export { ZenProvider } from './ZenProvider.js';
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Zen Setup CLI
|
||||
* Command-line tool for setting up Zen in a Next.js project
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import readline from 'readline';
|
||||
|
||||
// File templates
|
||||
const templates = {
|
||||
instrumentation: `// instrumentation.js
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initializeZen } = await import('@hykocx/zen');
|
||||
await initializeZen();
|
||||
}
|
||||
}
|
||||
`,
|
||||
authRedirect: `import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Redirect() {
|
||||
redirect('/auth/login/');
|
||||
}
|
||||
`,
|
||||
authCatchAll: `export { default } from '@hykocx/zen/auth/page';
|
||||
`,
|
||||
adminRedirect: `import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Redirect() {
|
||||
redirect('/admin/dashboard');
|
||||
}
|
||||
`,
|
||||
adminCatchAll: `export { default } from '@hykocx/zen/admin/page';
|
||||
`,
|
||||
zenApiRoute: `export { GET, POST, PUT, DELETE, PATCH } from '@hykocx/zen/zen/api';
|
||||
`,
|
||||
zenPageRoute: `export { default, generateMetadata } from '@hykocx/zen/modules/page';
|
||||
`,
|
||||
nextConfig: `// next.config.js
|
||||
module.exports = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
};
|
||||
`,
|
||||
};
|
||||
|
||||
// File definitions
|
||||
const files = [
|
||||
{
|
||||
path: 'instrumentation.js',
|
||||
template: 'instrumentation',
|
||||
description: 'Instrumentation file (initialize Zen)',
|
||||
},
|
||||
{
|
||||
path: 'app/(auth)/auth/page.js',
|
||||
template: 'authRedirect',
|
||||
description: 'Auth redirect page',
|
||||
},
|
||||
{
|
||||
path: 'app/(auth)/auth/[...auth]/page.js',
|
||||
template: 'authCatchAll',
|
||||
description: 'Auth catch-all route',
|
||||
},
|
||||
{
|
||||
path: 'app/(admin)/admin/page.js',
|
||||
template: 'adminRedirect',
|
||||
description: 'Admin redirect page',
|
||||
},
|
||||
{
|
||||
path: 'app/(admin)/admin/[...admin]/page.js',
|
||||
template: 'adminCatchAll',
|
||||
description: 'Admin catch-all route',
|
||||
},
|
||||
{
|
||||
path: 'app/zen/api/[...path]/route.js',
|
||||
template: 'zenApiRoute',
|
||||
description: 'Zen API catch-all route',
|
||||
},
|
||||
{
|
||||
path: 'app/zen/[...zen]/page.js',
|
||||
template: 'zenPageRoute',
|
||||
description: 'Zen public pages catch-all route',
|
||||
},
|
||||
];
|
||||
|
||||
async function createFile(filePath, content, force = false) {
|
||||
const fullPath = resolve(process.cwd(), filePath);
|
||||
|
||||
// Check if file already exists
|
||||
if (existsSync(fullPath) && !force) {
|
||||
console.log(`⏭️ Skipped (already exists): ${filePath}`);
|
||||
return { created: false, skipped: true };
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
const dir = dirname(fullPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
// Write the file
|
||||
await writeFile(fullPath, content, 'utf-8');
|
||||
console.log(`✅ Created: ${filePath}`);
|
||||
|
||||
return { created: true, skipped: false };
|
||||
}
|
||||
|
||||
async function setupZen(options = {}) {
|
||||
const { force = false } = options;
|
||||
|
||||
console.log('🚀 Setting up Zen for your Next.js project...\n');
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await createFile(
|
||||
file.path,
|
||||
templates[file.template],
|
||||
force
|
||||
);
|
||||
|
||||
if (result.created) created++;
|
||||
if (result.skipped) skipped++;
|
||||
}
|
||||
|
||||
console.log('\n📝 Summary:');
|
||||
console.log(` ✅ Created: ${created} file${created !== 1 ? 's' : ''}`);
|
||||
console.log(` ⏭️ Skipped: ${skipped} file${skipped !== 1 ? 's' : ''}`);
|
||||
|
||||
// Check if next.config.js needs updating
|
||||
const nextConfigPath = resolve(process.cwd(), 'next.config.js');
|
||||
const nextConfigExists = existsSync(nextConfigPath);
|
||||
|
||||
if (!nextConfigExists) {
|
||||
console.log('\n⚠️ Note: next.config.js not found.');
|
||||
console.log(' Make sure to enable instrumentation in your Next.js config:');
|
||||
console.log(' experimental: { instrumentationHook: true }');
|
||||
}
|
||||
|
||||
console.log('\n🎉 Setup complete!');
|
||||
console.log('\nNext steps:');
|
||||
console.log(' 1. Add Zen styles to your globals.css:');
|
||||
console.log(' @import \'@hykocx/zen/styles/zen.css\';');
|
||||
console.log(' 2. Configure environment variables (see .env.example)');
|
||||
console.log(' 3. Initialize the database:');
|
||||
console.log(' npx zen-db init');
|
||||
console.log('\nFor more information, check the INSTALL.md file.');
|
||||
}
|
||||
|
||||
async function listFiles() {
|
||||
console.log('📋 Files that will be created:\n');
|
||||
|
||||
for (const file of files) {
|
||||
const exists = existsSync(resolve(process.cwd(), file.path));
|
||||
const status = exists ? '✓ exists' : '✗ missing';
|
||||
console.log(` ${status} ${file.path}`);
|
||||
console.log(` ${file.description}`);
|
||||
}
|
||||
|
||||
console.log('\nRun "npx zen-setup init" to create missing files.');
|
||||
}
|
||||
|
||||
async function runCLI() {
|
||||
const command = process.argv[2];
|
||||
const flags = process.argv.slice(3);
|
||||
const force = flags.includes('--force') || flags.includes('-f');
|
||||
|
||||
if (!command || command === 'help') {
|
||||
console.log(`
|
||||
Zen Setup CLI
|
||||
|
||||
Usage:
|
||||
npx zen-setup <command> [options]
|
||||
|
||||
Commands:
|
||||
init Create all required files for Zen setup
|
||||
list List all files that will be created
|
||||
help Show this help message
|
||||
|
||||
Options:
|
||||
--force, -f Force overwrite existing files
|
||||
|
||||
Examples:
|
||||
npx zen-setup init # Create missing files
|
||||
npx zen-setup init --force # Overwrite all files
|
||||
npx zen-setup list # List all files
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'init':
|
||||
if (force) {
|
||||
console.log('⚠️ WARNING: --force flag will overwrite existing files!\n');
|
||||
console.log('Type "yes" to confirm or Ctrl+C to cancel...');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
rl.question('Confirm (yes/no): ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'yes') {
|
||||
await setupZen({ force: true });
|
||||
} else {
|
||||
console.log('❌ Operation cancelled.');
|
||||
}
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
return; // Don't exit yet
|
||||
} else {
|
||||
await setupZen({ force: false });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
await listFiles();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`❌ Unknown command: ${command}`);
|
||||
console.log('Run "npx zen-setup help" for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run CLI if called directly
|
||||
import { fileURLToPath } from 'url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
const __filename = realpathSync(fileURLToPath(import.meta.url));
|
||||
const isMainModule = process.argv[1] && realpathSync(process.argv[1]) === __filename;
|
||||
|
||||
if (isMainModule) {
|
||||
runCLI();
|
||||
}
|
||||
|
||||
export { runCLI, setupZen };
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Zen Setup Module
|
||||
* Utilities for setting up Zen in a Next.js project
|
||||
*/
|
||||
|
||||
export { setupZen } from './cli.js';
|
||||
@@ -0,0 +1,48 @@
|
||||
// Export database utilities as namespace
|
||||
export * as db from "./core/database/index.js";
|
||||
|
||||
// Export authentication module as namespace
|
||||
export * as auth from "./features/auth/index.js";
|
||||
|
||||
// Export admin module as namespace
|
||||
export * as admin from "./features/admin/index.js";
|
||||
|
||||
// Export API module as namespace
|
||||
export * as api from "./core/api/index.js";
|
||||
|
||||
// Export email utilities as namespace
|
||||
export * as email from "./core/email/index.js";
|
||||
|
||||
// Export cron utilities as namespace
|
||||
export * as cron from "./core/cron/index.js";
|
||||
|
||||
// Export payment utilities as namespace
|
||||
export * as payments from "./core/payments/index.js";
|
||||
export * as stripe from "./core/payments/stripe.js";
|
||||
|
||||
// Export PDF utilities as namespace
|
||||
export * as pdf from "./core/pdf/index.js";
|
||||
|
||||
// Export module system as namespace
|
||||
export * as moduleSystem from "./core/modules/index.js";
|
||||
|
||||
// NOTE: Toast components are CLIENT ONLY - import from '@hykocx/zen/toast'
|
||||
// Do not export here to avoid mixing client/server boundaries
|
||||
|
||||
// Export modules system as namespace (legacy, includes invoice module)
|
||||
export * as modules from "./modules/index.js";
|
||||
|
||||
// Export public pages (Zen routes)
|
||||
export { PublicPagesLayout, PublicPagesClient } from "./modules/pages.js";
|
||||
|
||||
// Export app configuration utilities
|
||||
export { getAppName, getAppConfig, getSessionCookieName, getModulesConfig, getPublicBaseUrl } from "./shared/lib/appConfig.js";
|
||||
|
||||
// Export initialization utilities
|
||||
export { initializeZen, resetZenInitialization } from "./shared/lib/init.js";
|
||||
|
||||
// Export date utilities
|
||||
export * as dates from "./shared/lib/dates.js";
|
||||
|
||||
// Export currency utilities
|
||||
export * as currency from "./shared/utils/currency.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import { getModulePublicPageLoader } from './modules.pages.js';
|
||||
import { Loading } from '../shared/components';
|
||||
|
||||
/**
|
||||
* Not Found Message Component
|
||||
*/
|
||||
function NotFoundMessage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
||||
<h1 className="text-2xl font-semibold text-white mb-4">Page non trouvée</h1>
|
||||
<p className="text-neutral-400">
|
||||
La page que vous recherchez n'existe pas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Module Pages Router
|
||||
* Handles routing for all public module pages dynamically
|
||||
*
|
||||
* Uses the client-side page loader from modules.pages.js instead of
|
||||
* the server-side registry (which is empty on the client).
|
||||
*/
|
||||
const PublicPagesClient = ({
|
||||
path = [],
|
||||
moduleActions = {},
|
||||
...additionalProps
|
||||
}) => {
|
||||
const moduleName = path[0];
|
||||
const PublicPage = getModulePublicPageLoader(moduleName);
|
||||
|
||||
if (PublicPage) {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<PublicPage
|
||||
path={path}
|
||||
{...moduleActions}
|
||||
{...additionalProps}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Module not found or no public pages
|
||||
return <NotFoundMessage />;
|
||||
};
|
||||
|
||||
export default PublicPagesClient;
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Public Module Pages Layout
|
||||
* Simple layout for public module pages like invoice payment
|
||||
*/
|
||||
const PublicPagesLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicPagesLayout;
|
||||
@@ -0,0 +1,284 @@
|
||||
# Module System
|
||||
|
||||
Modules are self-contained features that can be enabled/disabled via environment variables.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/modules/your-module/
|
||||
├── module.config.js # Required — navigation, pages, widgets
|
||||
├── db.js # Database schema (createTables / dropTables)
|
||||
├── crud.js # CRUD operations
|
||||
├── actions.js # Server actions (for public pages)
|
||||
├── metadata.js # SEO metadata generators
|
||||
├── api.js # API route handlers
|
||||
├── cron.config.js # Scheduled tasks
|
||||
├── index.js # Public API re-exports
|
||||
├── .env.example # Environment variable documentation
|
||||
├── admin/ # Admin pages (lazy-loaded)
|
||||
│ └── index.js # Re-exports admin components
|
||||
├── pages/ # Public pages (lazy-loaded)
|
||||
│ └── index.js
|
||||
├── dashboard/ # Dashboard widgets
|
||||
│ ├── statsActions.js
|
||||
│ └── Widget.js
|
||||
└── sub-feature/ # Optional sub-modules (e.g. items/, categories/)
|
||||
├── db.js
|
||||
├── crud.js
|
||||
└── admin/
|
||||
```
|
||||
|
||||
> Not all files are required. Only create what the module actually needs.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create `module.config.js`
|
||||
|
||||
```javascript
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default {
|
||||
// Module identity
|
||||
name: 'your-module',
|
||||
displayName: 'Your Module',
|
||||
version: '1.0.0',
|
||||
description: 'Description of your module',
|
||||
|
||||
// Other modules this one depends on (must be enabled too)
|
||||
dependencies: ['clients'],
|
||||
|
||||
// Environment variables this module uses (documentation only)
|
||||
envVars: [
|
||||
'YOUR_MODULE_API_KEY',
|
||||
],
|
||||
|
||||
// Admin navigation — single section object or array of section objects
|
||||
navigation: {
|
||||
id: 'your-module',
|
||||
title: 'Your Module',
|
||||
icon: 'SomeIcon', // String icon name from shared/Icons.js
|
||||
items: [
|
||||
{ name: 'Items', href: '/admin/your-module/items', icon: 'PackageIcon' },
|
||||
{ name: 'New', href: '/admin/your-module/items/new', icon: 'PlusSignIcon' },
|
||||
],
|
||||
},
|
||||
|
||||
// Admin pages — path → lazy component
|
||||
adminPages: {
|
||||
'/admin/your-module/items': lazy(() => import('./admin/ItemsListPage.js')),
|
||||
'/admin/your-module/items/new': lazy(() => import('./admin/ItemCreatePage.js')),
|
||||
'/admin/your-module/items/edit': lazy(() => import('./admin/ItemEditPage.js')),
|
||||
},
|
||||
|
||||
// (Optional) Custom resolver for dynamic paths not known at build time.
|
||||
// Called before the adminPages map. Return the lazy component or null.
|
||||
pageResolver(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
// example: /admin/your-module/{type}/list
|
||||
if (parts[2] === 'list') return lazy(() => import('./admin/ItemsListPage.js'));
|
||||
return null;
|
||||
},
|
||||
|
||||
// Public pages — keyed by 'default' (one component handles all public routes)
|
||||
publicPages: {
|
||||
default: lazy(() => import('./pages/YourModulePublicPages.js')),
|
||||
},
|
||||
|
||||
// Public route patterns for SEO/route matching (relative to /zen/your-module/)
|
||||
publicRoutes: [
|
||||
{ pattern: ':id', description: 'View item' },
|
||||
{ pattern: ':id/pdf', description: 'PDF viewer' },
|
||||
],
|
||||
|
||||
// Dashboard widgets (lazy-loaded, rendered on the admin dashboard)
|
||||
dashboardWidgets: [
|
||||
lazy(() => import('./dashboard/Widget.js')),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Navigation as multiple sections
|
||||
|
||||
When a module provides several distinct sections (like the `posts` module with one section per post type), set `navigation` to an array:
|
||||
|
||||
```javascript
|
||||
navigation: [
|
||||
{ id: 'your-module-foo', title: 'Foo', icon: 'Book02Icon', items: [...] },
|
||||
{ id: 'your-module-bar', title: 'Bar', icon: 'Layers01Icon', items: [...] },
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Create `db.js`
|
||||
|
||||
Every module that uses a database must expose a `createTables` function:
|
||||
|
||||
```javascript
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
const exists = await query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
)`, ['zen_your_module']);
|
||||
|
||||
if (!exists.rows[0].exists) {
|
||||
await query(`
|
||||
CREATE TABLE zen_your_module (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
created.push('zen_your_module');
|
||||
} else {
|
||||
skipped.push('zen_your_module');
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export async function dropTables() {
|
||||
await query(`DROP TABLE IF EXISTS zen_your_module CASCADE`);
|
||||
}
|
||||
```
|
||||
|
||||
> **Never create migrations.** Instead, provide the SQL to the user so they can run it manually.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Create `.env.example`
|
||||
|
||||
Document every environment variable the module reads:
|
||||
|
||||
```bash
|
||||
#################################
|
||||
# MODULE YOUR-MODULE
|
||||
ZEN_MODULE_YOUR_MODULE=false
|
||||
|
||||
ZEN_MODULE_YOUR_MODULE_API_KEY=
|
||||
ZEN_MODULE_YOUR_MODULE_SOME_OPTION=default_value
|
||||
#################################
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Create `cron.config.js` (optional)
|
||||
|
||||
Only needed if the module requires scheduled tasks:
|
||||
|
||||
```javascript
|
||||
import { doSomething } from './reminders.js';
|
||||
|
||||
export default {
|
||||
jobs: [
|
||||
{
|
||||
name: 'your-module-task',
|
||||
description: 'Description of what this job does',
|
||||
schedule: '*/5 * * * *', // cron expression
|
||||
handler: doSomething,
|
||||
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Register the module in 5 files
|
||||
|
||||
### `modules/modules.registry.js` — add the module name
|
||||
|
||||
```javascript
|
||||
export const AVAILABLE_MODULES = [
|
||||
'clients',
|
||||
'invoice',
|
||||
'your-module',
|
||||
];
|
||||
```
|
||||
|
||||
### `modules/modules.pages.js` — import the config
|
||||
|
||||
```javascript
|
||||
'use client';
|
||||
|
||||
import yourModuleConfig from './your-module/module.config.js';
|
||||
|
||||
const MODULE_CONFIGS = {
|
||||
// ...existing modules...
|
||||
'your-module': yourModuleConfig,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/modules.actions.js` — import server actions (if public pages or dashboard widgets)
|
||||
|
||||
```javascript
|
||||
import { yourPublicAction } from './your-module/actions.js';
|
||||
import { getYourModuleDashboardStats } from './your-module/dashboard/statsActions.js';
|
||||
|
||||
export const MODULE_ACTIONS = {
|
||||
// ...existing modules...
|
||||
'your-module': { yourPublicAction },
|
||||
};
|
||||
|
||||
export const MODULE_DASHBOARD_ACTIONS = {
|
||||
// ...existing modules...
|
||||
'your-module': getYourModuleDashboardStats,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/modules.metadata.js` — import metadata generators (if SEO needed)
|
||||
|
||||
```javascript
|
||||
import * as yourModuleMetadata from './your-module/metadata.js';
|
||||
|
||||
export const MODULE_METADATA = {
|
||||
// ...existing modules...
|
||||
'your-module': yourModuleMetadata,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/init.js` — register the database initializer
|
||||
|
||||
```javascript
|
||||
import { createTables as createYourModuleTables } from './your-module/db.js';
|
||||
|
||||
const MODULE_DB_INITIALIZERS = {
|
||||
// ...existing modules...
|
||||
'your-module': createYourModuleTables,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Enable the module
|
||||
|
||||
```bash
|
||||
ZEN_MODULE_YOUR_MODULE=true
|
||||
```
|
||||
|
||||
The environment variable is derived from the module name: `ZEN_MODULE_` + module name uppercased (hyphens become underscores).
|
||||
|
||||
---
|
||||
|
||||
## Sub-modules
|
||||
|
||||
For complex modules, group related features into sub-directories. Each sub-module follows the same pattern (`db.js`, `crud.js`, `admin/`). The parent `module.config.js` registers all sub-module admin pages, and the parent `index.js` re-exports everything publicly.
|
||||
|
||||
See `src/modules/invoice/` for a complete example with `items/`, `categories/`, `transactions/`, and `recurrences/` sub-modules.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementations
|
||||
|
||||
| Module | Features demonstrated |
|
||||
|--------|-----------------------|
|
||||
| `src/modules/invoice/` | Sub-modules, public pages, cron jobs, Stripe, email, PDF, dashboard widgets, metadata |
|
||||
| `src/modules/posts/` | Dynamic config from env vars, `pageResolver`, multiple navigation sections |
|
||||
| `src/modules/clients/` | Simple module, dependencies, no public pages |
|
||||
@@ -0,0 +1,4 @@
|
||||
#################################
|
||||
# MODULE CLIENTS
|
||||
ZEN_MODULE_CLIENTS=false
|
||||
#################################
|
||||
@@ -0,0 +1,37 @@
|
||||
# Clients Module Installation
|
||||
|
||||
## 1. Configure Environment Variables
|
||||
|
||||
Copy all variables from [`.env.example`](.env.example) and add them to your `.env` file.
|
||||
|
||||
## 2. Activate the Module
|
||||
|
||||
In your `.env` file, set:
|
||||
|
||||
```env
|
||||
ZEN_MODULE_CLIENTS=true
|
||||
```
|
||||
|
||||
## 3. Database Setup
|
||||
|
||||
Run the database initialization to create the required tables:
|
||||
|
||||
```bash
|
||||
npx zen-db init
|
||||
```
|
||||
|
||||
This will create the following table:
|
||||
- `zen_clients` - Stores client information
|
||||
|
||||
## 4. Features
|
||||
|
||||
### Client Management
|
||||
- Create, edit, and delete clients
|
||||
- Store contact information (name, email, phone, address)
|
||||
- Link clients to user accounts (optional)
|
||||
- Unique client numbers (auto-generated)
|
||||
|
||||
### Used By Other Modules
|
||||
The clients module is a dependency for:
|
||||
- **Quote Module**: Assign quotes to clients
|
||||
- **Invoice Module**: Assign invoices to clients
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '../../../shared/components';
|
||||
import ClientForm from './ClientForm.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* Client Create Page Component
|
||||
* Page for creating a new client
|
||||
*/
|
||||
const ClientCreatePage = ({ user }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const response = await fetch('/zen/api/admin/clients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ client: formData })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Client créé avec succès');
|
||||
router.push('/admin/clients/list');
|
||||
} else {
|
||||
toast.error(data.message || 'Échec de la création du client');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating client:', error);
|
||||
toast.error('Échec de la création du client');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer un client</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer un nouveau client</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/clients/list')}
|
||||
>
|
||||
← Retour aux clients
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ClientForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push('/admin/clients/list')}
|
||||
isEdit={false}
|
||||
saving={saving}
|
||||
users={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientCreatePage;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user