diff --git a/.npmignore b/.npmignore index 65f5e87..c593fe9 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,2 @@ /node_modules -/src +/src \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md index de13153..7fb8535 100644 --- a/README.md +++ b/README.md @@ -1 +1,117 @@ -# @nocobase-sample/plugin-shared-forms +# Auth + +提供基础认证功能和扩展认证器管理功能。 + +## 使用方法 + +### 认证器管理 +页面:系统设置 - 认证 + + + + +#### 内置认证器 +- 名称:basic +- 认证类型:邮箱密码登录 + + + + + +#### 增加认证器 +Add new - 选择认证类型 + + + +#### 启用/禁用 +Actions - Edit - 勾选/取消Enabled + + + +#### 配置认证器 +Actions - Edit + +## 开发一个登录插件 +### 接口 +Nocobase内核提供了扩展登录方式的接入和管理。扩展登录插件的核心逻辑处理,需要继承内核的`Auth`类,并对相应的标准接口进行实现。 +参考`core/auth/auth.ts` + +```TypeScript +import { Auth } from '@nocobase/auth'; + +class CustomAuth extends Auth { + set user(user) {} + get user() {} + + async check() {} + async signIn() {} +} +``` + +多数情况下,扩展的用户登录方式也将沿用现有的jwt逻辑来生成用户访问API的凭证,插件也可以继承`BaseAuth`类以便复用部分逻辑代码,如`check`, `signIn`接口。 + +```TypeScript +import { BaseAuth } from '@nocobase/auth'; + +class CustomAuth extends BaseAuth { + constructor(config: AuthConfig) { + const userCollection = config.ctx.db.getCollection('users'); + super({ ...config, userCollection }); + } + + async validate() {} +} +``` + +### 用户数据 + +`@nocobase/plugin-auth`插件提供了`usersAuthenticators`表来建立users和authenticators,即用户和认证方式的关联。 +通常情况下,扩展登录方式用`users`和`usersAuthenticators`来存储相应的用户数据即可,特殊情况下才需要自己新增Collection. +`users`存储的是最基础的用户数据,邮箱、昵称和密码。 +`usersAuthenticators`存储扩展登录方式数据 +- uuid: 该种认证方式的用户唯一标识,如手机号、微信openid等 +- meta: JSON字段,其他需要保存的信息 +- userId: 用户id +- authenticator:认证器名字 + +对于用户操作,`Authenticator`模型也提供了几个封装的方法,可以在`CustomAuth`类中通过`this.authenticator[方法名]`使用: +- `findUser(uuid: string): UserModel` - 查询用户 +- `newUser(uuid: string, values?: any): UserModel` - 创建新用户 +- `findOrCreateUser(uuid: string, userValues?: any): UserModel` - 查找或创建新用户 + +### 注册 +扩展的登录方式需要向内核注册。 +```TypeScript +async load() { + this.app.authManager.registerTypes('custom-auth-type', { + auth: CustomAuth, + }); +} +``` + +### 客户端API +#### OptionsComponentProvider +可供用户配置的认证器配置项 +- authType 认证方式 +- component 配置组件 +```TypeScript + +``` + +`Options`组件使用的值是`authenticator`的`options`字段,如果有需要暴露在前端的配置,应该放在`options.public`字段中。`authenticators:publicList`接口会返回`options.public`字段的值。 + +#### SigninPageProvider +自定义登录页界面 +- authType 认证方式 +- tabTitle 登录页tab标题 +- component 登录页组件 + +#### SignupPageProvider +自定义注册页界面 +- authType 认证方式 +- component 注册页组件 + +#### SigninPageExtensionProvider +自定义登录页下方的扩展内容 +- authType 认证方式 +- component 扩展组件 diff --git a/client.js b/client.js index b6e3be7..4d9520d 100644 --- a/client.js +++ b/client.js @@ -1 +1,65 @@ -module.exports = require('./dist/client/index.js'); +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./dist/client')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/package.json b/package.json index af999d0..3ada40f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,31 @@ { - "name": "@nocobase/plugin-shared-forms", + "name": "@LeWanYun/plugin-login", "version": "1.3.0-alpha", - "main": "dist/server/index.js", - "dependencies": {}, + "main": "./dist/server/index.js", + "devDependencies": { + "@ant-design/icons": "5.x", + "@formily/react": "2.x", + "@formily/shared": "2.x", + "@types/cron": "^2.0.1", + "antd": "5.x", + "cron": "^2.3.1", + "react": "^18.2.0", + "react-i18next": "^11.15.1" + }, "peerDependencies": { + "@nocobase/actions": "1.x", + "@nocobase/auth": "1.x", "@nocobase/client": "1.x", + "@nocobase/database": "1.x", "@nocobase/server": "1.x", "@nocobase/test": "1.x" - } + }, + "displayName": "Authentication", + "displayName.zh-CN": "LW用户认证", + "description": "User authentication management, including password, SMS, and support for Single Sign-On (SSO) protocols, with extensibility.", + "description.zh-CN": "用户认证管理,包括基础的密码认证、短信认证、SSO 协议的认证等,可扩展。", + "gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1", + "keywords": [ + "Authentication" + ] } diff --git a/server.js b/server.js index 9728420..6e7bf26 100644 --- a/server.js +++ b/server.js @@ -1 +1,65 @@ -module.exports = require('./dist/server/index.js'); +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./dist/server')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/src/client/AuthProvider.tsx b/src/client/AuthProvider.tsx new file mode 100644 index 0000000..f247068 --- /dev/null +++ b/src/client/AuthProvider.tsx @@ -0,0 +1,33 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useApp } from '@nocobase/client'; +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { LWAuthProvider } from './basic/code'; + +export const AuthProvider: React.FC = (props) => { + const location = useLocation(); + const app = useApp(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const authenticator = params.get('authenticator'); + const token = params.get('token'); + if (token) { + app.apiClient.auth.setToken(token); + app.apiClient.auth.setAuthenticator(authenticator); + } + }); + return ( + + <>{props.children} + + ); +}; diff --git a/src/client/ConfigureLink.tsx b/src/client/ConfigureLink.tsx deleted file mode 100644 index 9a42c47..0000000 --- a/src/client/ConfigureLink.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useFilterByTk } from '@nocobase/client'; -import React from 'react'; -import { Link } from 'react-router-dom'; - -export function ConfigureLink() { - const value = useFilterByTk(); - return Configure; -} diff --git a/src/client/PublicSharedForm.tsx b/src/client/PublicSharedForm.tsx deleted file mode 100644 index 821e94a..0000000 --- a/src/client/PublicSharedForm.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { SchemaComponent, SchemaComponentContext, useRequest } from '@nocobase/client'; -import { Spin } from 'antd'; -import React, { useContext } from 'react'; -import { useParams } from 'react-router'; -import { useCreateActionProps } from './useCreateActionProps'; - -export function PublicSharedForm() { - const params = useParams(); - const { data, loading } = useRequest({ - url: `sharedForms:getMeta/${params.name}`, - }); - const ctx = useContext(SchemaComponentContext); - if (loading) { - return ; - } - return ( -
- - - -
- ); -} diff --git a/src/client/SharedFormConfigure.tsx b/src/client/SharedFormConfigure.tsx deleted file mode 100644 index 34d4b5e..0000000 --- a/src/client/SharedFormConfigure.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { RemoteSchemaComponent } from '@nocobase/client'; -import { Breadcrumb, Button, Space } from 'antd'; -import React from 'react'; -import { useParams } from 'react-router'; -import { Link } from 'react-router-dom'; -import { useCreateActionProps } from './useCreateActionProps'; - -export function SharedFormConfigure() { - const params = useParams(); - return ( -
-
- Shared forms, - }, - { - title: 'Test', - }, - ]} - /> - - - - - -
-
- -
-
- ); -} diff --git a/src/client/SharedFormTable.tsx b/src/client/SharedFormTable.tsx deleted file mode 100644 index 8f2a53d..0000000 --- a/src/client/SharedFormTable.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import { createForm } from '@formily/core'; -import { useForm } from '@formily/react'; -import { uid } from '@formily/shared'; -import { - ActionProps, - ExtendCollectionsProvider, - ISchema, - SchemaComponent, - useActionContext, - useAPIClient, - useCollection, - useCollectionRecordData, - useDataBlockRequest, - useDataBlockResource, -} from '@nocobase/client'; -import { App as AntdApp } from 'antd'; -import React, { useMemo } from 'react'; -import { ConfigureLink } from './ConfigureLink'; - -const sharedFormsCollection = { - name: 'sharedForms', - filterTargetKey: 'slug', - fields: [ - { - type: 'string', - name: 'title', - interface: 'input', - uiSchema: { - type: 'string', - title: 'Title', - required: true, - 'x-component': 'Input', - }, - }, - { - type: 'text', - name: 'description', - interface: 'textarea', - uiSchema: { - type: 'string', - title: 'Description', - 'x-component': 'Input.TextArea', - }, - }, - { - type: 'string', - name: 'dataSource', - interface: 'input', - uiSchema: { - type: 'string', - title: 'Data source', - required: true, - 'x-component': 'Input', - }, - }, - { - type: 'string', - name: 'collection', - interface: 'collection', - uiSchema: { - type: 'string', - title: 'Collection', - required: true, - 'x-component': 'CollectionSelect', - }, - }, - { - type: 'password', - name: 'password', - interface: 'password', - uiSchema: { - type: 'string', - title: 'Password', - 'x-component': 'Password', - }, - }, - ], -}; - -const initialSchema = (values) => { - return { - type: 'void', - name: uid(), - properties: { - form: { - type: 'void', - 'x-toolbar': 'BlockSchemaToolbar', - 'x-toolbar-props': { - draggable: false, - }, - 'x-settings': 'blockSettings:createForm', - 'x-component': 'CardItem', - 'x-decorator': 'FormBlockProvider', - 'x-decorator-props': { - collection: values.collection, - dataSource: values.dataSource || 'main', - }, - 'x-use-decorator-props': 'useCreateFormBlockDecoratorProps', - properties: { - a69vmspkv8h: { - type: 'void', - 'x-component': 'FormV2', - 'x-use-component-props': 'useCreateFormBlockProps', - properties: { - grid: { - type: 'void', - 'x-component': 'Grid', - 'x-initializer': 'form:configureFields', - }, - l9xfwp6cfh1: { - type: 'void', - 'x-component': 'ActionBar', - 'x-initializer': 'createForm:configureActions', - 'x-component-props': { - layout: 'one-column', - }, - }, - }, - }, - }, - }, - success: { - type: 'void', - 'x-editable': false, - 'x-toolbar-props': { - draggable: false, - }, - 'x-settings': 'blockSettings:markdown', - 'x-component': 'Markdown.Void', - 'x-decorator': 'CardItem', - 'x-component-props': { - content: 'This is a demo text, **supports Markdown syntax**.', - }, - 'x-decorator-props': { - name: 'markdown', - engine: 'handlebars', - }, - }, - }, - }; -}; - -const useSubmitActionProps = () => { - const { setVisible } = useActionContext(); - const { message } = AntdApp.useApp(); - const form = useForm(); - const resource = useDataBlockResource(); - const { runAsync } = useDataBlockRequest(); - const collection = useCollection(); - const api = useAPIClient(); - return { - type: 'primary', - async onClick() { - await form.submit(); - const values = form.values; - if (values[collection.filterTargetKey]) { - await resource.update({ - values, - filterByTk: values[collection.filterTargetKey], - }); - } else { - const slug = uid(); - const schema = initialSchema(values); - schema['x-uid'] = slug; - await resource.create({ - values: { - ...values, - slug, - }, - }); - await api.resource('uiSchemas').insert({ values: schema }); - } - await runAsync(); - message.success('Saved successfully!'); - setVisible(false); - }, - }; -}; - -const useEditFormProps = () => { - const recordData = useCollectionRecordData(); - const form = useMemo( - () => - createForm({ - initialValues: recordData, - }), - [], - ); - - return { - form, - }; -}; - -function useDeleteActionProps(): ActionProps { - const { message } = AntdApp.useApp(); - const record = useCollectionRecordData(); - const resource = useDataBlockResource(); - const { runAsync } = useDataBlockRequest(); - const collection = useCollection(); - return { - confirm: { - title: 'Delete', - content: 'Are you sure you want to delete it?', - }, - async onClick() { - await resource.destroy({ - filterByTk: record[collection.filterTargetKey], - }); - await runAsync(); - message.success('Deleted!'); - }, - }; -} - -const schema: ISchema = { - type: 'void', - name: uid(), - 'x-component': 'CardItem', - 'x-decorator': 'TableBlockProvider', - 'x-decorator-props': { - collection: sharedFormsCollection.name, - action: 'list', - showIndex: true, - dragSort: false, - }, - properties: { - actions: { - type: 'void', - 'x-component': 'ActionBar', - 'x-component-props': { - style: { - marginBottom: 20, - }, - }, - properties: { - add: { - type: 'void', - 'x-component': 'Action', - title: 'Add New', - 'x-align': 'right', - 'x-component-props': { - type: 'primary', - }, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - title: 'Add new', - properties: { - form: { - type: 'void', - 'x-component': 'FormV2', - properties: { - title: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - required: true, - }, - dataSource: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - default: 'main', - }, - collection: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - required: true, - }, - description: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - }, - password: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - submit: { - title: 'Submit', - 'x-component': 'Action', - 'x-use-component-props': 'useSubmitActionProps', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - table: { - type: 'array', - 'x-component': 'TableV2', - 'x-use-component-props': 'useTableBlockProps', - 'x-component-props': { - rowKey: sharedFormsCollection.filterTargetKey, - rowSelection: { - type: 'checkbox', - }, - }, - properties: { - title: { - type: 'void', - title: 'Title', - 'x-component': 'TableV2.Column', - properties: { - title: { - type: 'string', - 'x-component': 'CollectionField', - 'x-pattern': 'readPretty', - }, - }, - }, - dataSource: { - type: 'void', - title: 'Data source', - 'x-component': 'TableV2.Column', - properties: { - dataSource: { - type: 'string', - 'x-component': 'CollectionField', - 'x-pattern': 'readPretty', - }, - }, - }, - collection: { - type: 'void', - title: 'Collection', - 'x-component': 'TableV2.Column', - properties: { - collection: { - type: 'string', - 'x-component': 'CollectionField', - 'x-pattern': 'readPretty', - }, - }, - }, - description: { - type: 'void', - title: 'Description', - 'x-component': 'TableV2.Column', - properties: { - description: { - type: 'string', - 'x-component': 'CollectionField', - 'x-pattern': 'readPretty', - }, - }, - }, - actions: { - type: 'void', - title: 'Actions', - 'x-component': 'TableV2.Column', - properties: { - actions: { - type: 'void', - 'x-component': 'Space', - 'x-component-props': { - split: '|', - }, - properties: { - configure: { - type: 'void', - title: 'Configure', - 'x-component': ConfigureLink, - // 'x-use-component-props': 'useDeleteActionProps', - }, - edit: { - type: 'void', - title: 'Edit', - 'x-component': 'Action.Link', - 'x-component-props': { - openMode: 'drawer', - icon: 'EditOutlined', - }, - properties: { - drawer: { - type: 'void', - title: 'Edit', - 'x-component': 'Action.Drawer', - properties: { - form: { - type: 'void', - 'x-component': 'FormV2', - 'x-use-component-props': 'useEditFormProps', - properties: { - title: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - required: true, - }, - description: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - required: true, - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - submit: { - title: 'Submit', - 'x-component': 'Action', - 'x-use-component-props': 'useSubmitActionProps', - }, - }, - }, - }, - }, - }, - }, - }, - }, - delete: { - type: 'void', - title: 'Delete', - 'x-component': 'Action.Link', - 'x-use-component-props': 'useDeleteActionProps', - }, - }, - }, - }, - }, - }, - }, - }, -}; - -export const SharedFormTable = () => { - return ( - - - - ); -}; diff --git a/src/client/__e2e__/auth.test.ts b/src/client/__e2e__/auth.test.ts new file mode 100644 index 0000000..7744cc8 --- /dev/null +++ b/src/client/__e2e__/auth.test.ts @@ -0,0 +1,44 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { expect, test } from '@nocobase/test/e2e'; + +test.describe('auth', () => { + // 重置登录状态 + test.use({ + storageState: { + cookies: [], + origins: [], + }, + }); + + test('register', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Create an account' }).click(); + await page.getByPlaceholder('Username').click(); + await page.getByPlaceholder('Username').fill('zidonghuaceshi'); + await page.getByPlaceholder('Password', { exact: true }).click(); + await page.getByPlaceholder('Password', { exact: true }).fill('zidonghuaceshi123'); + await page.getByPlaceholder('Confirm password').click(); + await page.getByPlaceholder('Confirm password').fill('zidonghuaceshi123'); + await page.getByRole('button', { name: 'Sign up' }).click(); + + await expect(page.getByText('Sign up successfully, and automatically jump to the sign in page')).toBeVisible(); + + // 用新账户登录 + await page.getByPlaceholder('Username/Email').click(); + await page.getByPlaceholder('Username/Email').fill('zidonghuaceshi'); + await page.getByPlaceholder('Password').click(); + await page.getByPlaceholder('Password').fill('zidonghuaceshi123'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await page.getByTestId('user-center-button').hover(); + await expect(page.getByText('zidonghuaceshi')).toBeVisible(); + }); +}); diff --git a/src/client/authenticator.ts b/src/client/authenticator.ts new file mode 100644 index 0000000..9c20517 --- /dev/null +++ b/src/client/authenticator.ts @@ -0,0 +1,29 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createContext, useContext } from 'react'; + +export type Authenticator = { + name: string; + authType: string; + authTypeTitle: string; + title?: string; + options?: { + [key: string]: any; + }; + sort?: number; +}; + +export const AuthenticatorsContext = createContext([]); +AuthenticatorsContext.displayName = 'AuthenticatorsContext'; + +export const useAuthenticator = (name: string) => { + const authenticators = useContext(AuthenticatorsContext); + return authenticators.find((a) => a.name === name); +}; diff --git a/src/client/basic/Options.tsx b/src/client/basic/Options.tsx new file mode 100644 index 0000000..6f531be --- /dev/null +++ b/src/client/basic/Options.tsx @@ -0,0 +1,48 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { SchemaComponent } from '@nocobase/client'; +import React from 'react'; +import { useAuthTranslation } from '../locale'; +import { Alert } from 'antd'; + +export const Options = () => { + const { t } = useAuthTranslation(); + return ( + + ); +}; diff --git a/src/client/basic/SignInForm.tsx b/src/client/basic/SignInForm.tsx new file mode 100644 index 0000000..fd4b888 --- /dev/null +++ b/src/client/basic/SignInForm.tsx @@ -0,0 +1,146 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ISchema } from '@formily/react'; +import { SchemaComponent, useAPIClient, useCurrentUserContext } from '@nocobase/client'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useAuthTranslation } from '../locale'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useForm } from '@formily/react'; +import { useSignUpForms } from '../pages'; +import { Authenticator } from '../authenticator'; + +import { useLWAuthContext } from '../basic/code'; + +export function useRedirect(next = '/admin') { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + return useCallback(() => { + navigate(searchParams.get('redirect') || '/admin', { replace: true }); + }, [navigate, searchParams]); +} + +export const useSignIn = (authenticator: string) => { + const form = useForm(); + const api = useAPIClient(); + const redirect = useRedirect(); + const { refreshAsync } = useCurrentUserContext(); + const { codeIsVisible, setCodeIsVisible, loginShow, setLoginShow, setChangePassword, setLWuserID } = + useLWAuthContext(); + + const setCodeShow = () => { + setCodeIsVisible(true); // 这将异步更新codeIsVisible的状态 + }; + + useEffect(() => { + const signInAsync = async () => { + if (loginShow) { + await form.submit(); + const { data } = await api.auth.signIn(form.values, authenticator); + await refreshAsync(); + redirect(); + // if (data.data.user.passwordLose == '200') { + // await refreshAsync(); + // redirect(); + // } else { + // setLWuserID(data.data.user.id); + // setChangePassword(true); + // form.values['password'] = ''; + // } + } + }; + signInAsync(); + setLoginShow(false); + }, [loginShow, setLoginShow, api.auth, authenticator, form, redirect, refreshAsync, setChangePassword, setLWuserID]); + + return { + async run() { + if (form.values['account'] === undefined || form.values['password'] === undefined) { + await form.submit(); + return; + } else { + console.log('form.values:', form.values); + setCodeShow(); + } + }, + }; +}; + +const passwordForm: ISchema = { + type: 'object', + name: 'passwordForm', + 'x-component': 'FormV2', + properties: { + account: { + type: 'string', + 'x-component': 'Input', + 'x-validator': `{{(value) => { + if (!value) { + return t("Please enter your username or email"); + } + if (value.includes('@')) { + if (!/^[\\w-]+(\\.[\\w-]+)*@[\\w-]+(\\.[\\w-]+)+$/.test(value)) { + return t("Please enter a valid email"); + } + } else { + return /^[^@.<>"'/]{1,50}$/.test(value) || t("Please enter a valid username"); + } + }}}`, + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Username/Email")}}', style: {} }, + }, + password: { + type: 'string', + 'x-component': 'Password', + required: true, + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Password")}}', style: {} }, + }, + actions: { + type: 'void', + 'x-component': 'div', + properties: { + submit: { + title: '{{t("Sign in")}}', + type: 'void', + 'x-component': 'Action', + 'x-component-props': { + htmlType: 'submit', + block: true, + type: 'primary', + useAction: `{{ useBasicSignIn }}`, + style: { width: '100%' }, + }, + }, + }, + }, + signUp: { + type: 'void', + 'x-component': 'Link', + 'x-component-props': { + to: '{{ signUpLink }}', + }, + 'x-content': '{{t("Create an account")}}', + 'x-visible': '{{ allowSignUp }}', + }, + }, +}; +export const SignInForm = (props: { authenticator: Authenticator }) => { + const { t } = useAuthTranslation(); + const authenticator = props.authenticator; + const { authType, name, options } = authenticator; + const signUpPages = useSignUpForms(); + const allowSignUp = signUpPages[authType] && options?.allowSignUp ? true : false; + const signUpLink = `/signup?name=${name}`; + + const useBasicSignIn = () => { + return useSignIn(name); + }; + return ; +}; diff --git a/src/client/basic/SignUpForm.tsx b/src/client/basic/SignUpForm.tsx new file mode 100644 index 0000000..fd7da1a --- /dev/null +++ b/src/client/basic/SignUpForm.tsx @@ -0,0 +1,137 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { SchemaComponent } from '@nocobase/client'; +import { ISchema } from '@formily/react'; +import React from 'react'; +import { uid } from '@formily/shared'; +import { useAuthTranslation } from '../locale'; +import { useAPIClient } from '@nocobase/client'; +import { useForm } from '@formily/react'; +import { useNavigate, Navigate } from 'react-router-dom'; +import { message } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useAuthenticator } from '../authenticator'; + +export interface UseSignupProps { + authenticator?: string; + message?: { + success?: string; + }; +} + +export const useSignUp = (props?: UseSignupProps) => { + const navigate = useNavigate(); + const form = useForm(); + const api = useAPIClient(); + const { t } = useTranslation(); + return { + async run() { + await form.submit(); + await api.auth.signUp(form.values, props?.authenticator); + message.success(props?.message?.success || t('Sign up successfully, and automatically jump to the sign in page')); + setTimeout(() => { + navigate('/signin'); + }, 2000); + }, + }; +}; + +const signupPageSchema: ISchema = { + type: 'object', + name: uid(), + 'x-component': 'FormV2', + properties: { + username: { + type: 'string', + required: true, + 'x-component': 'Input', + 'x-validator': { username: true }, + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Username")}}', style: {} }, + }, + password: { + type: 'string', + required: true, + 'x-component': 'Password', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Password")}}', checkStrength: true, style: {} }, + 'x-reactions': [ + { + dependencies: ['.confirm_password'], + fulfill: { + state: { + selfErrors: '{{$deps[0] && $self.value && $self.value !== $deps[0] ? t("Password mismatch") : ""}}', + }, + }, + }, + ], + }, + confirm_password: { + type: 'string', + required: true, + 'x-component': 'Password', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} }, + 'x-reactions': [ + { + dependencies: ['.password'], + fulfill: { + state: { + selfErrors: '{{$deps[0] && $self.value && $self.value !== $deps[0] ? t("Password mismatch") : ""}}', + }, + }, + }, + ], + }, + actions: { + type: 'void', + 'x-component': 'div', + properties: { + submit: { + title: '{{t("Sign up")}}', + type: 'void', + 'x-component': 'Action', + 'x-component-props': { + block: true, + type: 'primary', + htmlType: 'submit', + useAction: '{{ useBasicSignUp }}', + style: { width: '100%' }, + }, + }, + }, + }, + link: { + type: 'void', + 'x-component': 'div', + properties: { + link: { + type: 'void', + 'x-component': 'Link', + 'x-component-props': { to: '/signin' }, + 'x-content': '{{t("Log in with an existing account")}}', + }, + }, + }, + }, +}; + +export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: string }) => { + const { t } = useAuthTranslation(); + const useBasicSignUp = () => { + return useSignUp({ authenticator: name }); + }; + const authenticator = useAuthenticator(name); + const { options } = authenticator; + if (!options?.allowSignUp) { + return ; + } + return ; +}; diff --git a/src/client/basic/code.tsx b/src/client/basic/code.tsx new file mode 100644 index 0000000..f839b92 --- /dev/null +++ b/src/client/basic/code.tsx @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { FC, useState, createContext, useContext, useEffect } from 'react'; +const LWAuthLayoutContext = createContext({ + codeIsVisible: false, + setCodeIsVisible: (isVisible: boolean) => {}, + loginShow: false, + setLoginShow: (isVisible: boolean) => {}, + changePassword: false, + setChangePassword: (isVisible: boolean) => {}, + LWuserID: '', + setLWuserID: (id: string) => {}, +}); +export const useLWAuthContext = () => useContext(LWAuthLayoutContext); + +export const LWAuthProvider = ({ children }) => { + const [codeIsVisible, setCodeIsVisible] = useState(false); + const [loginShow, setLoginShow] = useState(false); + const [changePassword, setChangePassword] = useState(false); + const [LWuserID, setLWuserID] = useState(''); + + return ( + + {children} + + ); +}; diff --git a/src/client/basic/index.ts b/src/client/basic/index.ts new file mode 100644 index 0000000..8ef05b3 --- /dev/null +++ b/src/client/basic/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './SignInForm'; +export * from './SignUpForm'; +export * from './Options'; diff --git a/src/client/client.d.ts b/src/client/client.d.ts deleted file mode 100644 index 4e96f83..0000000 --- a/src/client/client.d.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -// CSS modules -type CSSModuleClasses = { readonly [key: string]: string }; - -declare module '*.module.css' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.scss' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.sass' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.less' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.styl' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.stylus' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.pcss' { - const classes: CSSModuleClasses; - export default classes; -} -declare module '*.module.sss' { - const classes: CSSModuleClasses; - export default classes; -} - -// CSS -declare module '*.css' { } -declare module '*.scss' { } -declare module '*.sass' { } -declare module '*.less' { } -declare module '*.styl' { } -declare module '*.stylus' { } -declare module '*.pcss' { } -declare module '*.sss' { } - -// Built-in asset types -// see `src/node/constants.ts` - -// images -declare module '*.apng' { - const src: string; - export default src; -} -declare module '*.png' { - const src: string; - export default src; -} -declare module '*.jpg' { - const src: string; - export default src; -} -declare module '*.jpeg' { - const src: string; - export default src; -} -declare module '*.jfif' { - const src: string; - export default src; -} -declare module '*.pjpeg' { - const src: string; - export default src; -} -declare module '*.pjp' { - const src: string; - export default src; -} -declare module '*.gif' { - const src: string; - export default src; -} -declare module '*.svg' { - const src: string; - export default src; -} -declare module '*.ico' { - const src: string; - export default src; -} -declare module '*.webp' { - const src: string; - export default src; -} -declare module '*.avif' { - const src: string; - export default src; -} - -// media -declare module '*.mp4' { - const src: string; - export default src; -} -declare module '*.webm' { - const src: string; - export default src; -} -declare module '*.ogg' { - const src: string; - export default src; -} -declare module '*.mp3' { - const src: string; - export default src; -} -declare module '*.wav' { - const src: string; - export default src; -} -declare module '*.flac' { - const src: string; - export default src; -} -declare module '*.aac' { - const src: string; - export default src; -} -declare module '*.opus' { - const src: string; - export default src; -} -declare module '*.mov' { - const src: string; - export default src; -} -declare module '*.m4a' { - const src: string; - export default src; -} -declare module '*.vtt' { - const src: string; - export default src; -} - -// fonts -declare module '*.woff' { - const src: string; - export default src; -} -declare module '*.woff2' { - const src: string; - export default src; -} -declare module '*.eot' { - const src: string; - export default src; -} -declare module '*.ttf' { - const src: string; - export default src; -} -declare module '*.otf' { - const src: string; - export default src; -} - -// other -declare module '*.webmanifest' { - const src: string; - export default src; -} -declare module '*.pdf' { - const src: string; - export default src; -} -declare module '*.txt' { - const src: string; - export default src; -} - -// wasm?init -declare module '*.wasm?init' { - const initWasm: (options?: WebAssembly.Imports) => Promise; - export default initWasm; -} - -// web worker -declare module '*?worker' { - const workerConstructor: { - new(options?: { name?: string }): Worker; - }; - export default workerConstructor; -} - -declare module '*?worker&inline' { - const workerConstructor: { - new(options?: { name?: string }): Worker; - }; - export default workerConstructor; -} - -declare module '*?worker&url' { - const src: string; - export default src; -} - -declare module '*?sharedworker' { - const sharedWorkerConstructor: { - new(options?: { name?: string }): SharedWorker; - }; - export default sharedWorkerConstructor; -} - -declare module '*?sharedworker&inline' { - const sharedWorkerConstructor: { - new(options?: { name?: string }): SharedWorker; - }; - export default sharedWorkerConstructor; -} - -declare module '*?sharedworker&url' { - const src: string; - export default src; -} - -declare module '*?raw' { - const src: string; - export default src; -} - -declare module '*?url' { - const src: string; - export default src; -} - -declare module '*?inline' { - const src: string; - export default src; -} diff --git a/src/client/index.tsx b/src/client/index.tsx index 5bb4b09..0ad2f52 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,26 +1,80 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + import { Plugin } from '@nocobase/client'; -import { PublicSharedForm } from './PublicSharedForm'; -import { SharedFormConfigure } from './SharedFormConfigure'; -import { SharedFormTable } from './SharedFormTable'; +import { Registry } from '@nocobase/utils/client'; +import { ComponentType } from 'react'; +import { presetAuthType } from '../preset'; +import { AuthProvider } from './AuthProvider'; +import { Authenticator as AuthenticatorType } from './authenticator'; +import { Options, SignInForm, SignUpForm } from './basic'; +import { NAMESPACE } from './locale'; +import { AuthLayout, SignInPage, SignUpPage } from './pages'; +import { Authenticator } from './settings/Authenticator'; +export { AuthenticatorsContextProvider, AuthLayout } from './pages/AuthLayout'; + +export type AuthOptions = { + components: Partial<{ + SignInForm: ComponentType<{ authenticator: AuthenticatorType }>; + SignInButton: ComponentType<{ authenticator: AuthenticatorType }>; + SignUpForm: ComponentType<{ authenticatorName: string }>; + AdminSettingsForm: ComponentType; + }>; +}; + +export class PluginAuthClient extends Plugin { + authTypes = new Registry(); + + registerType(authType: string, options: AuthOptions) { + this.authTypes.register(authType, options); + } -export class PluginSharedFormsClient extends Plugin { async load() { - this.app.router.add('shared-forms', { - path: '/shared-forms/:name', - Component: PublicSharedForm, + this.app.pluginSettingsManager.add(NAMESPACE, { + icon: 'LoginOutlined', + title: `{{t("Authentication", { ns: "${NAMESPACE}" })}}`, + Component: Authenticator, + aclSnippet: 'pm.auth.authenticators', + }); + + this.router.add('auth', { + Component: 'AuthLayout', }); - this.app.pluginSettingsManager.add('shared-forms', { - title: 'Shared forms', - icon: 'TableOutlined', - Component: SharedFormTable, + this.router.add('auth.signin', { + path: '/signin', + Component: 'SignInPage', }); - this.app.pluginSettingsManager.add(`shared-forms/:name`, { - title: false, - pluginKey: 'shared-forms', - isTopLevel: false, - Component: SharedFormConfigure, + this.router.add('auth.signup', { + path: '/signup', + Component: 'SignUpPage', + }); + + this.app.addComponents({ + AuthLayout, + SignInPage, + SignUpPage, + }); + + this.app.providers.unshift([AuthProvider, {}]); + + this.registerType(presetAuthType, { + components: { + SignInForm: SignInForm, + SignUpForm: SignUpForm, + AdminSettingsForm: Options, + }, }); } } -export default PluginSharedFormsClient; +export { AuthenticatorsContext, useAuthenticator } from './authenticator'; +export type { Authenticator } from './authenticator'; +export { useSignIn } from './basic'; + +export default PluginAuthClient; diff --git a/src/client/locale.ts b/src/client/locale.ts deleted file mode 100644 index 7e451b2..0000000 --- a/src/client/locale.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useApp } from '@nocobase/client'; -// @ts-ignore -import pkg from './../../package.json'; - -export function useT() { - const app = useApp(); - return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] }); -} - -export function tStr(key: string) { - return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`; -} diff --git a/src/client/locale/index.ts b/src/client/locale/index.ts new file mode 100644 index 0000000..57ba718 --- /dev/null +++ b/src/client/locale/index.ts @@ -0,0 +1,16 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useTranslation } from 'react-i18next'; + +export const NAMESPACE = 'auth'; + +export function useAuthTranslation() { + return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' }); +} diff --git a/src/client/pages/AuthLayout.tsx b/src/client/pages/AuthLayout.tsx new file mode 100644 index 0000000..9aa2004 --- /dev/null +++ b/src/client/pages/AuthLayout.tsx @@ -0,0 +1,238 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { css } from '@emotion/css'; +import React, { FC, useEffect, useState } from 'react'; +import { Outlet } from 'react-router-dom'; +import { useSystemSettings, PoweredBy, useRequest, useAPIClient } from '@nocobase/client'; +import { AuthenticatorsContext } from '../authenticator'; +import { Input, Spin, Button } from 'antd'; +import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons'; +import axios from 'axios'; + +export const AuthenticatorsContextProvider: FC<{ children: React.ReactNode }> = ({ children }) => { + const api = useAPIClient(); + const { + data: authenticators = [], + error, + loading, + } = useRequest(() => + api + .resource('authenticators') + .publicList() + .then((res) => { + return res?.data?.data || []; + }), + ); + + if (loading) { + return ; + } + + if (error) { + throw error; + } + + return {children}; +}; + +import './js/tac'; +import './css/tac.css'; +import './css/AuthLayout.css'; +// @ts-ignore +import logoUrl from './assets/logo.png'; +import { useLWAuthContext } from '../basic/code'; + +export function AuthLayout() { + const { data } = useSystemSettings(); + const { codeIsVisible, setCodeIsVisible, setLoginShow, changePassword, setChangePassword, LWuserID } = + useLWAuthContext(); + const lwUrl = 'https://v8dev.lewanyun.com'; + useEffect(() => { + if (codeIsVisible) { + const configData = { + // 生成接口 + requestCaptchaDataUrl: `${lwUrl}/magic/gen?type=SLIDER`, + // 验证接口 + validCaptchaUrl: `${lwUrl}/magic/check`, + // 验证码绑定的div块 + bindEl: '#captcha-box', + // 验证成功回调函数 + validSuccess: (res, c, tac) => { + // 销毁验证码服务 + tac.destroyWindow(); + // 调用登录方法 + if (res.success) { + setCodeIsVisible(false); + setLoginShow(true); + } + }, + // 验证失败回调函数 + validFail: (res, c, tac) => { + tac.reloadCaptcha(); + }, + // 刷新按钮回调事件 + btnRefreshFun: (el, tac) => { + tac.reloadCaptcha(); + }, + // 关闭按钮回调事件 + btnCloseFun: (el, tac) => { + setCodeIsVisible(false); + }, + }; + const style = { + logoUrl, + }; + // @ts-ignore + new TAC(configData, style).init(); + } + }, [codeIsVisible, setCodeIsVisible, setLoginShow, lwUrl, setChangePassword]); + + const [newPassword, setNewPassword] = useState(''); + const [newPasswordTwo, setNewPasswordTwo] = useState(''); + const [newStatus, setNewStatus] = useState(); + const [newStatusTwo, setNewStatusTwo] = useState(); + const [statusShow, setStatusShow] = useState(false); + + const onClickFrom = () => { + if (newPassword !== newPasswordTwo) { + // @ts-ignore + setNewStatus('error'); + // @ts-ignore + setNewStatusTwo('error'); + setStatusShow(true); + return; + } else { + // @ts-ignore + setNewStatus('success'); + // @ts-ignore + setNewStatusTwo('success'); + setStatusShow(false); + changePasswordFun(); + setChangePassword(false); + } + }; + + const changePasswordFun = async () => { + try { + const response = await axios.post( + `https://v8dev.lewanyun.com/api/users:update?filterByTk=${LWuserID}`, + { + password: newPasswordTwo, + passwordLose: '200', + }, + { + headers: { + 'X-App': 'a_ygky', + Authorization: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjMsInJvbGVOYW1lIjoiYWRtaW4iLCJpYXQiOjE3MjM0NTE5MjQsImV4cCI6MzMyODEwNTE5MjR9.quDi9Np6cQUTgFM1dGJpwrXdnr7-iWLsCmr-_mxUqLo', + }, + }, + ); + + if (response.status === 200) { + setNewPassword(''); + setNewPasswordTwo(''); + console.log('Password change successful:', response.data); + } else { + console.error('Unexpected response status:', response.status); + } + } catch (error) { + console.error('Error changing password:', error); + } + }; + + return ( +
+

{data?.data?.title}

+ + + +
+ +
+ {codeIsVisible && ( +
+
+
+
+
+ )} + {changePassword && ( +
+
+
密码过期修改密码
+
+
+
新密码:
+ {statusShow &&
与第二次密码输入不一致!!!
} + { + setNewPassword(e.target.value); + }} + iconRender={(visible) => (visible ? : )} + /> +
+
+
确认新密码:
+ {statusShow &&
与第一次密码输入不一致!!!
} + { + if (newPassword !== e.target.value) { + // @ts-ignore + setNewStatusTwo('error'); + // @ts-ignore + setNewStatus('error'); + setStatusShow(true); + } else { + // @ts-ignore + setNewStatusTwo('success'); + // @ts-ignore + setNewStatus('success'); + setStatusShow(false); + } + }} + onChange={(e) => { + setNewPasswordTwo(e.target.value); + }} + iconRender={(visible) => (visible ? : )} + /> +
+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/src/client/pages/SignInPage.tsx b/src/client/pages/SignInPage.tsx new file mode 100644 index 0000000..3b88168 --- /dev/null +++ b/src/client/pages/SignInPage.tsx @@ -0,0 +1,105 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { css } from '@emotion/css'; +import { Space, Tabs } from 'antd'; +import React, { createElement, useContext } from 'react'; +import { useCurrentDocumentTitle, usePlugin, useViewport } from '@nocobase/client'; +import AuthPlugin, { AuthOptions } from '..'; +import { Authenticator, AuthenticatorsContext } from '../authenticator'; +import { useAuthTranslation } from '../locale'; +import { Schema } from '@formily/react'; + +export const useSignInForms = (): { + [authType: string]: AuthOptions['components']['SignInForm']; +} => { + const plugin = usePlugin(AuthPlugin); + const authTypes = plugin.authTypes.getEntities(); + const signInForms = {}; + for (const [authType, options] of authTypes) { + if (options.components?.SignInForm) { + signInForms[authType] = options.components.SignInForm; + } + } + return signInForms; +}; + +export const useSignInButtons = (authenticators = []) => { + const plugin = usePlugin(AuthPlugin); + const authTypes = plugin.authTypes.getEntities(); + const customs = {}; + for (const [authType, options] of authTypes) { + if (options.components?.SignInButton) { + customs[authType] = options.components.SignInButton; + } + } + + const types = Object.keys(customs); + return authenticators + .filter((authenticator) => types.includes(authenticator.authType)) + .map((authenticator, index) => React.createElement(customs[authenticator.authType], { key: index, authenticator })); +}; + +export const SignInPage = () => { + const { t } = useAuthTranslation(); + useCurrentDocumentTitle('Signin'); + useViewport(); + const signInForms = useSignInForms(); + const authenticators = useContext(AuthenticatorsContext); + const signInButtons = useSignInButtons(authenticators); + + if (!authenticators.length) { + return
{t('No authentication methods available.')}
; + } + + const tabs = authenticators + .map((authenticator) => { + const C = signInForms[authenticator.authType]; + if (!C) { + return; + } + const defaultTabTitle = `${t('Sign-in')} (${Schema.compile( + authenticator.authTypeTitle || authenticator.authType, + { t }, + )})`; + return { + component: createElement<{ + authenticator: Authenticator; + }>(C, { authenticator }), + tabTitle: authenticator.title || defaultTabTitle, + ...authenticator, + }; + }) + .filter((i) => i); + + return ( + + {tabs.length > 1 ? ( + ({ label: tab.tabTitle, key: tab.name, children: tab.component }))} /> + ) : tabs.length ? ( +
{tabs[0].component}
+ ) : ( + <> + )} + + {signInButtons} + +
+ ); +}; diff --git a/src/client/pages/SignUpPage.tsx b/src/client/pages/SignUpPage.tsx new file mode 100644 index 0000000..175536a --- /dev/null +++ b/src/client/pages/SignUpPage.tsx @@ -0,0 +1,64 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useCurrentDocumentTitle, usePlugin, useViewport } from '@nocobase/client'; +import React, { useContext, createContext, FunctionComponent, createElement } from 'react'; +import { Navigate, useSearchParams } from 'react-router-dom'; +import AuthPlugin, { AuthOptions } from '..'; +import { useAuthenticator } from '../authenticator'; + +export const SignupPageContext = createContext<{ + [authType: string]: { + component: FunctionComponent<{ + name: string; + }>; + }; +}>({}); +SignupPageContext.displayName = 'SignupPageContext'; + +export const SignupPageProvider: React.FC<{ + authType: string; + component: FunctionComponent<{ + name: string; + }>; +}> = (props) => { + const components = useContext(SignupPageContext); + components[props.authType] = { + component: props.component, + }; + return {props.children}; +}; + +export const useSignUpForms = (): { + [authType: string]: AuthOptions['components']['SignUpForm']; +} => { + const plugin = usePlugin(AuthPlugin); + const authTypes = plugin.authTypes.getEntities(); + const signUpForms = {}; + for (const [authType, options] of authTypes) { + if (options.components?.SignUpForm) { + signUpForms[authType] = options.components.SignUpForm; + } + } + return signUpForms; +}; + +export const SignUpPage = () => { + useViewport(); + useCurrentDocumentTitle('Signup'); + const signUpForms = useSignUpForms(); + const [searchParams] = useSearchParams(); + const name = searchParams.get('name'); + const authenticator = useAuthenticator(name); + const { authType } = authenticator || {}; + if (!signUpForms[authType]) { + return ; + } + return createElement(signUpForms[authType], { authenticatorName: name }); +}; diff --git a/src/client/pages/assets/icon.png b/src/client/pages/assets/icon.png new file mode 100644 index 0000000..586a123 Binary files /dev/null and b/src/client/pages/assets/icon.png differ diff --git a/src/client/pages/assets/lewan.png b/src/client/pages/assets/lewan.png new file mode 100644 index 0000000..5c1dfb5 Binary files /dev/null and b/src/client/pages/assets/lewan.png differ diff --git a/src/client/pages/assets/loginBg.png b/src/client/pages/assets/loginBg.png new file mode 100644 index 0000000..09f0aa9 Binary files /dev/null and b/src/client/pages/assets/loginBg.png differ diff --git a/src/client/pages/assets/logo.png b/src/client/pages/assets/logo.png new file mode 100644 index 0000000..f83367d Binary files /dev/null and b/src/client/pages/assets/logo.png differ diff --git a/src/client/pages/css/AuthLayout.css b/src/client/pages/css/AuthLayout.css new file mode 100644 index 0000000..044a923 --- /dev/null +++ b/src/client/pages/css/AuthLayout.css @@ -0,0 +1,141 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +.codeBody { + position: absolute; + top: 0%; + left: 0%; + width: 100vw; + height: 100vh; + background-color: #00000020; +} +.codeBody .code { + position: absolute; + top: 36%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + justify-content: center; + align-items: center; + width: calc(444 * 100vw / 1920); + height: calc(565 * 100vw / 1920); + /* background-color: #ffffff; */ + border-radius: calc(12 * 100vw / 1920); +} +.codeBody .code #captcha-box #tianai-captcha-parent #tianai-captcha-box { + height: 90%; +} +.codeBody .code #captcha-box #tianai-captcha-parent .slider-bottom { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + height: 10%; +} +.codeBody .code #captcha-box #tianai-captcha-parent .slider-bottom .close-btn { + position: absolute; + top: 50%; + right: 10%; + transform: translate(-50%, -50%); + cursor: pointer; + width: 20px; + height: 20px; + background-image: url(../assets/icon.png); + background-repeat: no-repeat; + background-position: 0 -40px; +} +.codeBody .code #captcha-box #tianai-captcha-parent .slider-bottom .refresh-btn { + cursor: pointer; + width: 20px; + height: 20px; + background-image: url(../assets/icon.png); + background-repeat: no-repeat; + background-position: 0 -193px; +} +.changePassword { + position: absolute; + top: 0%; + left: 0%; + width: 100vw; + height: 100vh; + background-color: #00000020; +} +.changePassword .changePassword-body { + padding: calc(16 * 100vw / 1920) calc(20 * 100vw / 1920); + position: absolute; + top: 30%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(680 * 100vw / 1920); + height: calc(285 * 100vw / 1920); + background-color: #ffffff; + border-radius: calc(12 * 100vw / 1920); +} +.changePassword .changePassword-body .changePassword-title { + position: relative; + font-size: calc(20 * 100vw / 1920); + color: #333333; + margin-bottom: calc(20 * 100vw / 1920); + margin-left: calc(12 * 100vw / 1920); + font-weight: 600; +} +.changePassword .changePassword-body .changePassword-title::before { + position: absolute; + top: 56%; + left: -2%; + transform: translate(-50%, -50%); + content: ' '; + display: inline-block; + width: calc(4 * 100vw / 1920); + height: calc(24 * 100vw / 1920); + background-color: #1890ff; +} +.changePassword .changePassword-body .changePassword-form { + padding: calc(0 * 100vw / 1920) calc(48 * 100vw / 1920); + width: 100%; +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-item { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: calc(72 * 100vw / 1920); + margin-bottom: calc(8 * 100vw / 1920); +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-item .text { + position: relative; + width: calc(120 * 100vw / 1920); + font-size: calc(16 * 100vw / 1920); + color: #333333; + font-weight: 600; +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-item .text::before { + position: absolute; + top: 0%; + left: -6%; + transform: translate(-50%, -50%); + content: '*'; + display: inline-block; + width: calc(4 * 100vw / 1920); + height: calc(4 * 100vw / 1920); + color: #ff0004; +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-item .statustext { + position: absolute; + bottom: calc(-4 * 100vw / 1920); + left: calc(100 * 100vw / 1920); + font-size: calc(12 * 100vw / 1920); + color: #ff0004; + font-weight: 500; +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-item input { + height: calc(28 * 100vw / 1920); +} +.changePassword .changePassword-body .changePassword-form .changePassword-form-button { + width: 100%; + display: flex; + justify-content: flex-end; +} diff --git a/src/client/pages/css/tac.css b/src/client/pages/css/tac.css new file mode 100644 index 0000000..98e6fd9 --- /dev/null +++ b/src/client/pages/css/tac.css @@ -0,0 +1,8 @@ +#tianai-captcha-parent{box-shadow:0 0 11px 0 #999;width:318px;height:318px;overflow:hidden;position:relative;z-index:997;box-sizing:border-box;border-radius:5px;padding:8px}#tianai-captcha-parent #tianai-captcha-box{height:260px;width:100%;position:relative;overflow:hidden}#tianai-captcha-parent #tianai-captcha-box .loading{width:50px;height:50px;text-align:center;display:block;z-index:998;position:absolute;top:105px;left:126px;-moz-user-select:none;-webkit-user-select:none;user-select:none}#tianai-captcha-parent #tianai-captcha-box #tianai-captcha{transform-style:preserve-3d;will-change:transform;transition-duration:.5s;transition-timing-function:cubic-bezier(0.36, 0.3, 0.42, 1.5);transform:translateX(-300px)}#tianai-captcha-parent #tianai-captcha-bg-img{background-color:#fff;background-position:top;background-size:cover;z-index:-1;width:100%;height:100%;top:0;left:0;position:absolute;border-radius:6px}#tianai-captcha-parent .slider-bottom{height:19px;width:100%}#tianai-captcha-parent .slider-bottom .close-btn{width:20px;height:20px;background-image:url(../assets/icon.png);background-repeat:no-repeat;background-position:0 -14px;float:right;margin-right:2px;cursor:pointer}#tianai-captcha-parent .slider-bottom .refresh-btn{width:20px;height:20px;background-image:url(../assets/icon.png);background-position:0 -167px;background-repeat:no-repeat;float:right;margin-right:10px;cursor:pointer}#tianai-captcha-parent .slider-bottom .logo{height:30px;float:left}#tianai-captcha-parent .slider-move-shadow{animation:myanimation 2s infinite;height:100%;width:5px;background-color:#fff;position:absolute;top:0;left:0;filter:opacity(0.5);box-shadow:1px 1px 1px #fff;border-radius:50%}#tianai-captcha-parent #tianai-captcha-slider-move-track-mask{border-width:1px;border-style:solid;border-color:#00f4ab;width:0;height:32px;background-color:#a9ffe5;opacity:.5;position:absolute;top:-1px;left:-1px;border-radius:5px} +#tianai-captcha{text-align:left;box-sizing:content-box;width:300px;height:260px;z-index:999}#tianai-captcha .slider-bottom .logo{height:30px}#tianai-captcha .slider-bottom{height:19px;width:100%}#tianai-captcha .content .tianai-captcha-tips{height:25px;width:100%;position:absolute;bottom:-25px;left:0;z-index:999;font-size:15px;line-height:25px;color:#fff;text-align:center;transition:bottom .3s ease-in-out}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-error{background-color:#ff5d39}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-success{background-color:#39c522}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-on{bottom:0}#tianai-captcha .content #tianai-captcha-loading{z-index:9999;background-color:#f5f5f5;text-align:center;height:100%;overflow:hidden;position:relative;display:flex;justify-content:center;align-items:center}#tianai-captcha .content #tianai-captcha-loading img{display:block;width:45px;height:45px}#tianai-captcha #tianai-captcha-slider-bg-canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:5px}@keyframes myanimation{from{left:0}to{left:289px}} +#tianai-captcha.tianai-captcha-slider{z-index:999;position:absolute;left:0;top:0;user-select:none}#tianai-captcha.tianai-captcha-slider .content{width:100%;height:180px;position:relative;overflow:hidden}#tianai-captcha.tianai-captcha-slider .bg-img-div{width:100%;height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .bg-img-div img{height:100%;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-img-div{height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .slider-img-div #tianai-captcha-slider-move-img{height:100%}#tianai-captcha.tianai-captcha-slider .slider-move{height:34px;width:100%;margin:11px 0;position:relative}#tianai-captcha.tianai-captcha-slider .slider-move-track{position:relative;height:32px;line-height:32px;text-align:center;background:#f5f5f5;color:#999;transition:0s;font-size:14px;box-sizing:content-box;border:1px solid #f5f5f5;border-radius:4px}#tianai-captcha.tianai-captcha-slider .refresh-btn,#tianai-captcha.tianai-captcha-slider .close-btn{display:inline-block}#tianai-captcha.tianai-captcha-slider .slider-move{line-height:38px;font-size:14px;text-align:center;white-space:nowrap;color:#88949d;-moz-user-select:none;-webkit-user-select:none;user-select:none;filter:opacity(0.8)}#tianai-captcha.tianai-captcha-slider .slider-move .slider-move-btn{transform:translate(0px, 0px);position:absolute;top:-6px;left:0;width:63px;height:45px;background-color:#fff;background-repeat:no-repeat;background-size:contain;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-tip{margin-bottom:5px;font-weight:bold;font-size:15px;line-height:normal;color:#000}#tianai-captcha.tianai-captcha-slider .slider-move-btn:hover{cursor:pointer} +#tianai-captcha.tianai-captcha-rotate .rotate-img-div{height:100%;text-align:center}#tianai-captcha.tianai-captcha-rotate .rotate-img-div img{height:100%;transform:rotate(0deg);display:inline-block} +#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-img-div{background-size:100% 180px;position:absolute;transform:translate(0px, 0px);z-index:1;width:100%}#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-bg-img{width:100%;height:100%;position:absolute;transform:translate(0px, 0px);background-size:100% 180px} +#tianai-captcha.tianai-captcha-word-click{position:relative;box-sizing:border-box}#tianai-captcha.tianai-captcha-word-click .click-tip{position:relative;height:40px;width:100%}#tianai-captcha.tianai-captcha-word-click .click-tip .tip-img{width:130px;position:absolute;right:15px}#tianai-captcha.tianai-captcha-word-click .click-tip #tianai-captcha-click-track-font{font-size:20px;display:inline-block;height:40px;line-height:40px;position:absolute}#tianai-captcha.tianai-captcha-word-click .slider-bottom{position:relative;top:6px}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask{width:100%;height:100%;position:absolute;left:0;top:0}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask .click-span{position:absolute;left:0;top:0;border-radius:50px;background-color:#409eff;width:20px;height:20px;text-align:center;line-height:20px;color:#fff;border:2px solid #fff} +#tianai-captcha.tianai-captcha-rotate2{position:relative;user-select:none}#tianai-captcha.tianai-captcha-rotate2 #tianai-captcha-bg-img{background-color:#fff;background-position:top;background-size:cover;z-index:-1;width:100%;height:100%;top:0;left:0;position:absolute;border-radius:6px}#tianai-captcha.tianai-captcha-rotate2 .content{width:100%;height:180px;position:relative;overflow:hidden}#tianai-captcha.tianai-captcha-rotate2 .content .mask{height:180px;width:180px;position:absolute;border:2px solid #fff;z-index:99;left:60px;border-radius:50%;box-sizing:border-box}#tianai-captcha.tianai-captcha-rotate2 .bg-img-div{width:100%;height:100%;position:absolute;transform:translate(0px, 0px);text-align:center}#tianai-captcha.tianai-captcha-rotate2 .bg-img-div img{height:100%;border-radius:50%}#tianai-captcha.tianai-captcha-rotate2 .slider-img-div{height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-rotate2 .slider-img-div img{height:100%}#tianai-captcha.tianai-captcha-rotate2 .slider-move{height:60px;width:100%;margin:11px 0;position:relative}#tianai-captcha.tianai-captcha-rotate2 .slider-move .slider-move-track{line-height:38px;font-size:14px;text-align:center;white-space:nowrap;color:#88949d;-moz-user-select:none;-webkit-user-select:none;user-select:none;filter:opacity(0.8)}#tianai-captcha.tianai-captcha-rotate2 .slider-move .slider-move-btn{transform:translate(0px, 0px);position:absolute;top:-6px;left:0;width:63px;height:45px;background-color:#fff;background-repeat:no-repeat;background-size:contain;border-radius:5px}#tianai-captcha.tianai-captcha-rotate2 .slider-bottom{height:19px;width:100%}#tianai-captcha.tianai-captcha-rotate2 .slider-bottom .close-btn{width:20px;height:20px;background-image:url(../assets/icon.png);background-repeat:no-repeat;background-position:0 -14px;float:right;margin-right:2px}#tianai-captcha.tianai-captcha-rotate2 .slider-bottom .refresh-btn{width:20px;height:20px;background-image:url(../assets/icon.png);background-position:0 -167px;background-repeat:no-repeat;float:right;margin-right:10px}#tianai-captcha.tianai-captcha-rotate2 .slider-move-track{position:relative;height:32px;line-height:32px;text-align:center;background:#f5f5f5;color:#999;transition:0s;font-size:14px;box-sizing:content-box;border:1px solid #f5f5f5;border-radius:4px}#tianai-captcha.tianai-captcha-rotate2 .refresh-btn,#tianai-captcha.tianai-captcha-rotate2 .close-btn{display:inline-block}#tianai-captcha.tianai-captcha-rotate2 .slider-tip{margin-bottom:5px;font-weight:bold;font-size:15px}#tianai-captcha.tianai-captcha-rotate2 .slider-move-btn:hover,#tianai-captcha.tianai-captcha-rotate2 .tianai-captcha-rotate2 .close-btn:hover,#tianai-captcha.tianai-captcha-rotate2 .tianai-captcha-rotate2 .refresh-btn:hover{cursor:pointer}#tianai-captcha.tianai-captcha-rotate2 #tianai-captcha-slider-move-track-mask{border-width:1px;border-style:solid;width:0;height:32px;background-color:#f7b645;opacity:.5;position:absolute;top:-1px;left:-1px;border-radius:5px;border-color:#ef9c0d}#tianai-captcha.tianai-captcha-rotate2 .rotate-img-div{height:100%;position:absolute;transform:rotate(0deg);margin-left:58px}#tianai-captcha.tianai-captcha-rotate2 .rotate-img-div img{height:100%}#tianai-captcha.tianai-captcha-rotate2 .tianai-captcha-slider-bg-img-mask{position:absolute;left:60px;top:0;width:180px;height:180px;float:left;background-size:100%;filter:opacity(0.9);display:none}#tianai-captcha.tianai-captcha-rotate2 #tianai-captcha-slider-bg-degree-canvas{position:absolute;top:0;width:180px;height:180px;left:60px;border-radius:50%;transform:rotate(0deg)} +#tianai-captcha.tianai-captcha-scrape #tianai-captcha-scrape-tip-img{display:inline-block;height:20px}#tianai-captcha.tianai-captcha-scrape #tianai-captcha-slider-move-track-font{color:gray}#tianai-captcha.tianai-captcha-scrape .slider-img-div{height:100%;width:100%;position:absolute;background-color:#d8d8d8;transform:translate(0px, 0px);border-radius:0 5px 5px 0;box-sizing:border-box;top:0;box-shadow:-6 px 0px 9px 0px #fff} diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts new file mode 100644 index 0000000..7410c11 --- /dev/null +++ b/src/client/pages/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './AuthLayout'; +export * from './SignInPage'; +export * from './SignUpPage'; diff --git a/src/client/pages/js/tac.js b/src/client/pages/js/tac.js new file mode 100644 index 0000000..7ab2a44 --- /dev/null +++ b/src/client/pages/js/tac.js @@ -0,0 +1,1579 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +(() => { + 'use strict'; + var t, + e, + a = { + 783: (t, e, a) => { + var i = a(618), + r = Object.create(null), + n = 'undefined' == typeof document, + c = Array.prototype.forEach; + function s() {} + function o(t, e) { + if (!e) { + if (!t.href) return; + e = t.href.split('?')[0]; + } + if (l(e) && !1 !== t.isLoaded && e && e.indexOf('.css') > -1) { + t.visited = !0; + var a = t.cloneNode(); + (a.isLoaded = !1), + a.addEventListener('load', function () { + a.isLoaded || ((a.isLoaded = !0), t.parentNode.removeChild(t)); + }), + a.addEventListener('error', function () { + a.isLoaded || ((a.isLoaded = !0), t.parentNode.removeChild(t)); + }), + (a.href = ''.concat(e, '?').concat(Date.now())), + t.nextSibling ? t.parentNode.insertBefore(a, t.nextSibling) : t.parentNode.appendChild(a); + } + } + function d(t) { + if (!t) return !1; + var e = document.querySelectorAll('link'), + a = !1; + return ( + c.call(e, function (e) { + if (e.href) { + var r = (function (t, e) { + var a; + return ( + (t = i(t)), + e.some(function (i) { + t.indexOf(e) > -1 && (a = i); + }), + a + ); + })(e.href, t); + l(r) && !0 !== e.visited && r && (o(e, r), (a = !0)); + } + }), + a + ); + } + function h() { + var t = document.querySelectorAll('link'); + c.call(t, function (t) { + !0 !== t.visited && o(t); + }); + } + function l(t) { + return !!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(t); + } + t.exports = function (t, e) { + if (n) return s; + var a, + c, + o, + l = (function (t) { + var e = r[t]; + if (!e) { + if (document.currentScript) e = document.currentScript.src; + else { + var a = document.getElementsByTagName('script'), + n = a[a.length - 1]; + n && (e = n.src); + } + r[t] = e; + } + return function (t) { + if (!e) return null; + var a = e.split(/([^\\/]+)\.js$/), + r = a && a[1]; + return r && t + ? t.split(',').map(function (t) { + var a = new RegExp(''.concat(r, '\\.js$'), 'g'); + return i(e.replace(a, ''.concat(t.replace(/{fileName}/g, r), '.css'))); + }) + : [e.replace('.js', '.css')]; + }; + })(t); + return ( + (a = function () { + var t = d(l(e.filename)); + e.locals ? h() : t || h(); + }), + (c = 50), + (o = 0), + function () { + var t = this, + e = arguments; + clearTimeout(o), + (o = setTimeout(function () { + return a.apply(t, e); + }, c)); + } + ); + }; + }, + 618: (t) => { + t.exports = function (t) { + if (((t = t.trim()), /^data:/i.test(t))) return t; + var e = -1 !== t.indexOf('//') ? t.split('//')[0] + '//' : '', + a = t.replace(new RegExp(e, 'i'), '').split('/'), + i = a[0].toLowerCase().replace(/\.$/, ''); + return ( + (a[0] = ''), + e + + i + + a + .reduce(function (t, e) { + switch (e) { + case '..': + t.pop(); + break; + case '.': + break; + default: + t.push(e); + } + return t; + }, []) + .join('/') + ); + }; + }, + 488: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 523: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 991: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 492: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 305: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 444: (t, e, a) => { + var i = a(783)(t.id, { locals: !1 }); + t.hot.dispose(i), t.hot.accept(void 0, i); + }, + 600: (t, e, a) => { + a(488), a(523), a(444); + function i(t) { + t.preventDefault && t.preventDefault(); + } + function r(t) { + A(t).each((t) => { + t.addEventListener('touchmove', i, { passive: !1 }), t.addEventListener('mousemove', i, { passive: !1 }); + }); + } + function n(t) { + if (null !== t.pageX && void 0 !== t.pageX) return { x: Math.round(t.pageX), y: Math.round(t.pageY) }; + let e; + return ( + t.changedTouches + ? (e = t.changedTouches) + : t.targetTouches + ? (e = t.targetTouches) + : t.originalEvent && t.originalEvent.targetTouches && (e = t.originalEvent.targetTouches), + null !== e[0].pageX && void 0 !== e[0].pageX + ? { x: Math.round(e[0].pageX), y: Math.round(e[0].pageY) } + : { x: Math.round(e[0].clientX), y: Math.round(e[0].clientY) } + ); + } + function c(t) { + const e = n(t); + let a = e.x, + i = e.y; + (currentCaptcha.currentCaptchaData.startX = a), (currentCaptcha.currentCaptchaData.startY = i); + const r = currentCaptcha.currentCaptchaData.startX, + c = currentCaptcha.currentCaptchaData.startY, + o = currentCaptcha.currentCaptchaData.startTime; + currentCaptcha.currentCaptchaData.trackArr.push({ + x: r - a, + y: c - i, + type: 'down', + t: new Date().getTime() - o.getTime(), + }), + window.addEventListener('mousemove', s), + window.addEventListener('mouseup', d), + window.addEventListener('touchmove', s, !1), + window.addEventListener('touchend', d, !1), + window.currentCaptcha.doDown && window.currentCaptcha.doDown(t, window.currentCaptcha); + } + function s(t) { + t.touches && t.touches.length > 0 && (t = t.touches[0]); + const e = n(t); + let a = e.x, + i = e.y; + const r = window.currentCaptcha.currentCaptchaData.startX, + c = window.currentCaptcha.currentCaptchaData.startY, + s = window.currentCaptcha.currentCaptchaData.startTime, + o = window.currentCaptcha.currentCaptchaData.end, + d = window.currentCaptcha.currentCaptchaData.bgImageWidth, + h = window.currentCaptcha.currentCaptchaData.trackArr; + let l = a - r, + p = i - c; + const u = { x: a - r, y: i - c, type: 'move', t: new Date().getTime() - s.getTime() }; + h.push(u), + l < 0 ? (l = 0) : l > o && (l = o), + (window.currentCaptcha.currentCaptchaData.moveX = l), + (window.currentCaptcha.currentCaptchaData.movePercent = l / d), + (window.currentCaptcha.currentCaptchaData.moveY = p), + window.currentCaptcha.doMove && window.currentCaptcha.doMove(t, currentCaptcha); + } + function o() { + window.removeEventListener('mousemove', s), + window.removeEventListener('mouseup', d), + window.removeEventListener('touchmove', s), + window.removeEventListener('touchend', d); + } + function d(t) { + o(); + const e = n(t); + currentCaptcha.currentCaptchaData.stopTime = new Date(); + let a = e.x, + i = e.y; + const r = currentCaptcha.currentCaptchaData.startX, + c = currentCaptcha.currentCaptchaData.startY, + s = currentCaptcha.currentCaptchaData.startTime, + d = currentCaptcha.currentCaptchaData.trackArr, + h = { x: a - r, y: i - c, type: 'up', t: new Date().getTime() - s.getTime() }; + d.push(h), + window.currentCaptcha.doUp && window.currentCaptcha.doUp(t, window.currentCaptcha), + window.currentCaptcha.endCallback(currentCaptcha.currentCaptchaData, currentCaptcha); + } + function h(t, e, a, i, r) { + const n = { + startTime: new Date(), + trackArr: [], + movePercent: 0, + clickCount: 0, + bgImageWidth: Math.round(t), + bgImageHeight: Math.round(e), + templateImageWidth: Math.round(a), + templateImageHeight: Math.round(i), + end: r, + }; + return n; + } + function l(t, e) { + A(t).find('#tianai-captcha-tips').removeClass('tianai-captcha-tips-on'), e && setTimeout(e, 0.35); + } + function p(t, e, a, i) { + const r = A(t).find('#tianai-captcha-tips'); + r.text(e), + 1 === a + ? (r.removeClass('tianai-captcha-tips-error'), r.addClass('tianai-captcha-tips-success')) + : (r.removeClass('tianai-captcha-tips-success'), r.addClass('tianai-captcha-tips-error')), + r.addClass('tianai-captcha-tips-on'), + setTimeout(i, 1e3); + } + class u { + showTips(t, e, a) { + p(this.el, t, e, a); + } + closeTips(t, e) { + l(this.el, t); + } + } + function A(t, e) { + return new g(t, e); + } + class g { + constructor(t, e) { + if (e && 'object' == typeof e && void 0 !== e.nodeType) return (this.dom = e), void (this.domStr = t); + if (t instanceof g) (this.dom = t.dom), (this.domStr = t.domStr); + else if ('string' == typeof t) (this.dom = document.querySelector(t)), (this.domStr = t); + else { + if ('object' != typeof document || void 0 === document.nodeType) throw new Error('不支持的类型'); + (this.dom = t), (this.domStr = t.nodeName); + } + } + each(t) { + this.getTarget().querySelectorAll('*').forEach(t); + } + removeClass(t) { + let e = this.getTarget(); + if (e.classList) e.classList.remove(t); + else { + const a = e.className, + i = new RegExp('(?:^|\\s)' + t + '(?!\\S)', 'g'); + e.className = a.replace(i, ''); + } + return this; + } + addClass(t) { + const e = this.getTarget(); + if (e.classList) e.classList.add(t); + else { + let a = e.className; + -1 === a.indexOf(t) && (e.className = a + ' ' + t); + } + return this; + } + find(t) { + const e = this.getTarget().querySelector(t); + return e ? new g(t, e) : null; + } + children(t) { + const e = this.getTarget().childNodes; + for (let a = 0; a < e.length; a++) if (1 === e[a].nodeType && e[a].matches(t)) return new g(t, e[a]); + return null; + } + remove() { + return this.getTarget().remove(), null; + } + css(t, e) { + if ('string' == typeof t && 'string' == typeof e) this.getTarget().style[t] = e; + else if ('object' == typeof t) for (var a in t) t.hasOwnProperty(a) && (this.getTarget().style[a] = t[a]); + else if ('string' == typeof t && void 0 === e) return window.getComputedStyle(element)[t]; + } + attr(t, e) { + return void 0 === e ? this.getTarget().getAttribute(t) : (this.getTarget().setAttribute(t, e), this); + } + text(t) { + return (this.getTarget().innerText = t), this; + } + html(t) { + return (this.getTarget().innerHtml = t), this; + } + is(t) { + return t && 'object' == typeof t && void 0 !== t.nodeType + ? this.dom === t + : t instanceof g + ? this.dom === t.dom + : void 0; + } + append(t) { + if ('string' == typeof t) this.getTarget().insertAdjacentHTML('beforeend', t); + else { + if (!(t instanceof HTMLElement)) throw new Error('Invalid content type'); + this.getTarget().appendChild(t); + } + return this; + } + click(t) { + return this.on('click', t), this; + } + mousedown(t) { + return this.on('mousedown', t), this; + } + touchstart(t) { + return this.on('touchstart', t), this; + } + on(t, e) { + return this.getTarget().addEventListener(t, e), this; + } + width() { + return this.getTarget().offsetWidth; + } + height() { + return this.getTarget().offsetHeight; + } + getTarget() { + if (this.dom) return this.dom; + throw new Error('dom不存在: [' + this.domStr + ']'); + } + } + const f = class extends u { + constructor(t, e) { + super(), + (this.boxEl = A(t)), + (this.styleConfig = e), + (this.type = 'SLIDER'), + (this.currentCaptchaData = {}); + } + init(t, e, a) { + return ( + this.destroy(), + this.boxEl.append( + (this.styleConfig, + '\n
\n
\n 拖动滑块完成拼图\n
\n
\n
\n \n \n
\n
\n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n\n
\n'), + ), + (this.el = this.boxEl.find('#tianai-captcha')), + this.loadStyle(), + this.el.find('#tianai-captcha-slider-move-btn').mousedown(c), + this.el.find('#tianai-captcha-slider-move-btn').touchstart(c), + (window.currentCaptcha = this), + this.loadCaptchaForData(this, t), + (this.endCallback = e), + a && a(this), + this + ); + } + showTips(t, e, a) { + p(this.el, t, e, a); + } + closeTips(t) { + l(this.el, t); + } + destroy() { + const t = this.boxEl.children('#tianai-captcha'); + t && t.remove(), o(); + } + doMove() { + const t = this.currentCaptchaData.moveX; + this.el.find('#tianai-captcha-slider-move-btn').css('transform', 'translate(' + t + 'px, 0px)'), + this.el.find('#tianai-captcha-slider-img-div').css('transform', 'translate(' + t + 'px, 0px)'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('width', t + 'px'); + } + loadStyle() { + let t = '', + e = '#00f4ab', + a = '#a9ffe5'; + const i = this.styleConfig; + i && ((t = i.btnUrl), (a = i.moveTrackMaskBgColor), (e = i.moveTrackMaskBorderColor)), + this.el.find('.slider-move .slider-move-btn').css('background-image', 'url(' + t + ')'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('border-color', e), + this.el.find('#tianai-captcha-slider-move-track-mask').css('background-color', a); + } + loadCaptchaForData(t, e) { + const a = t.el.find('#tianai-captcha-slider-bg-img'), + i = t.el.find('#tianai-captcha-slider-move-img'); + a.attr('src', e.captcha.backgroundImage), + i.attr('src', e.captcha.templateImage), + a.on('load', () => { + (t.currentCaptchaData = h(a.width(), a.height(), i.width(), i.height(), 242)), + (t.currentCaptchaData.currentCaptchaId = e.id); + }); + } + }; + a(305); + const m = class extends u { + constructor(t, e) { + super(), + (this.boxEl = A(t)), + (this.styleConfig = e), + (this.type = 'ROTATE'), + (this.currentCaptchaData = {}); + } + init(t, e, a) { + return ( + this.destroy(), + this.boxEl.append( + (this.styleConfig, + '\n
\n
\n 拖动滑块完成拼图\n
\n
\n
\n \n \n
\n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n'), + ), + (this.el = this.boxEl.find('#tianai-captcha')), + this.loadStyle(), + this.el.find('#tianai-captcha-slider-move-btn').mousedown(c), + this.el.find('#tianai-captcha-slider-move-btn').touchstart(c), + (window.currentCaptcha = this), + this.loadCaptchaForData(this, t), + (this.endCallback = e), + a && a(this), + this + ); + } + destroy() { + const t = this.boxEl.children('#tianai-captcha'); + t && t.remove(), o(); + } + doMove() { + const t = this.currentCaptchaData.moveX; + this.el.find('#tianai-captcha-slider-move-btn').css('transform', 'translate(' + t + 'px, 0px)'), + this.el + .find('#tianai-captcha-slider-move-img') + .css('transform', 'rotate(' + t / (this.currentCaptchaData.end / 360) + 'deg)'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('width', t + 'px'); + } + loadStyle() { + let t = '', + e = '#00f4ab', + a = '#a9ffe5'; + const i = this.styleConfig; + i && ((t = i.btnUrl), (a = i.moveTrackMaskBgColor), (e = i.moveTrackMaskBorderColor)), + this.el.find('.slider-move .slider-move-btn').css('background-image', 'url(' + t + ')'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('border-color', e), + this.el.find('#tianai-captcha-slider-move-track-mask').css('background-color', a); + } + loadCaptchaForData(t, e) { + const a = t.el.find('#tianai-captcha-slider-bg-img'), + i = t.el.find('#tianai-captcha-slider-move-img'); + a.attr('src', e.captcha.backgroundImage), + i.attr('src', e.captcha.templateImage), + a.on('load', () => { + (t.currentCaptchaData = h(a.width(), a.height(), i.width(), i.height(), 242)), + (t.currentCaptchaData.currentCaptchaId = e.id); + }); + } + }; + a(991); + const v = class extends u { + constructor(t, e) { + super(), + (this.boxEl = A(t)), + (this.styleConfig = e), + (this.type = 'CONCAT'), + (this.currentCaptchaData = {}); + } + init(t, e, a) { + return ( + this.destroy(), + this.boxEl.append( + (this.styleConfig, + '\n
\n
\n 拖动滑块完成拼图\n
\n
\n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n '), + ), + (this.el = this.boxEl.find('#tianai-captcha')), + this.loadStyle(), + this.el.find('#tianai-captcha-slider-move-btn').mousedown(c), + this.el.find('#tianai-captcha-slider-move-btn').touchstart(c), + r(this.el), + (window.currentCaptcha = this), + this.loadCaptchaForData(this, t), + (this.endCallback = e), + a && a(this), + this + ); + } + destroy() { + o(); + const t = this.boxEl.children('#tianai-captcha'); + t && t.remove(); + } + doMove() { + const t = this.currentCaptchaData.moveX; + this.el.find('#tianai-captcha-slider-move-btn').css('transform', 'translate(' + t + 'px, 0px)'), + this.el.find('#tianai-captcha-slider-concat-img-div').css('background-position-x', t + 'px'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('width', t + 'px'); + } + loadStyle() { + let t = '', + e = '#00f4ab', + a = '#a9ffe5'; + const i = this.styleConfig; + i && ((t = i.btnUrl), (a = i.moveTrackMaskBgColor), (e = i.moveTrackMaskBorderColor)), + this.el.find('.slider-move .slider-move-btn').css('background-image', 'url(' + t + ')'), + this.el.find('#tianai-captcha-slider-move-track-mask').css('border-color', e), + this.el.find('#tianai-captcha-slider-move-track-mask').css('background-color', a); + } + loadCaptchaForData(t, e) { + const a = t.el.find('.tianai-captcha-slider-concat-bg-img'), + i = t.el.find('#tianai-captcha-slider-concat-img-div'); + a.css('background-image', 'url(' + e.captcha.backgroundImage + ')'), + i.css('background-image', 'url(' + e.captcha.backgroundImage + ')'), + i.css('background-position', '0px 0px'); + var r = e.captcha.backgroundImageHeight, + n = ((r - e.captcha.data.randomY) / r) * 180; + i.css('height', n + 'px'), + (t.currentCaptchaData = h(a.width(), a.height(), i.width(), i.height(), 242)), + (t.currentCaptchaData.currentCaptchaId = e.id); + } + }; + a(492); + const C = class extends u { + constructor(t, e) { + super(), + (this.boxEl = A(t)), + (this.styleConfig = e), + (this.type = 'IMAGE_CLICK'), + (this.currentCaptchaData = {}); + } + init(t, e, a) { + return ( + this.destroy(), + this.boxEl.append( + (this.styleConfig, + '\n
\n
\n 请依次点击:\n \n
\n
\n
\n \n \n
\n
\n
\n
\n
\n'), + ), + (this.el = this.boxEl.find('#tianai-captcha')), + (window.currentCaptcha = this), + this.loadCaptchaForData(this, t), + (this.endCallback = e), + this.el.find('#bg-img-click-mask').click((t) => { + this.currentCaptchaData.clickCount++; + const e = this.currentCaptchaData.trackArr, + a = this.currentCaptchaData.startTime; + 1 === this.currentCaptchaData.clickCount && + (window.addEventListener('mousemove', s), + (this.currentCaptchaData.startX = t.offsetX), + (this.currentCaptchaData.startY = t.offsetY)), + e.push({ + x: Math.round(t.offsetX), + y: Math.round(t.offsetY), + type: 'click', + t: new Date().getTime() - a.getTime(), + }); + const i = t.offsetX - 10, + r = t.offsetY - 10; + this.el + .find('#bg-img-click-mask') + .append( + "" + + this.currentCaptchaData.clickCount + + '', + ), + 4 === this.currentCaptchaData.clickCount && + ((this.currentCaptchaData.stopTime = new Date()), + window.removeEventListener('mousemove', s), + this.endCallback(this.currentCaptchaData, this)); + }), + a && a(this), + this + ); + } + destroy() { + const t = this.boxEl.children('#tianai-captcha'); + t && t.remove(), o(); + } + loadCaptchaForData(t, e) { + const a = t.el.find('#tianai-captcha-slider-bg-img'), + i = t.el.find('#tianai-captcha-tip-img'); + a.on('load', () => { + (t.currentCaptchaData = h(a.width(), a.height(), i.width(), i.height())), + (t.currentCaptchaData.currentCaptchaId = e.id); + }), + a.attr('src', e.captcha.backgroundImage), + i.attr('src', e.captcha.templateImage); + } + }; + const w = class extends C { + constructor(t, e) { + super(t, e), (this.type = 'WORD_IMAGE_CLICK'); + } + }; + class b { + constructor(t) { + if (!t.bindEl) throw new Error('[TAC] 必须配置 [bindEl]用于将验证码绑定到该元素上'); + if (!t.requestCaptchaDataUrl) throw new Error('[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口'); + if (!t.validCaptchaUrl) throw new Error('[TAC] 必须配置 [validCaptchaUrl]验证验证码接口'); + (this.bindEl = t.bindEl), + (this.domBindEl = A(t.bindEl)), + (this.requestCaptchaDataUrl = t.requestCaptchaDataUrl), + (this.validCaptchaUrl = t.validCaptchaUrl), + t.validSuccess && (this.validSuccess = t.validSuccess), + t.validFail && (this.validFail = t.validFail), + t.requestHeaders ? (this.requestHeaders = t.requestHeaders) : (this.requestHeaders = {}), + t.btnCloseFun && (this.btnCloseFun = t.btnCloseFun), + t.btnRefreshFun && (this.btnRefreshFun = t.btnRefreshFun), + (this.requestChain = []), + (this.timeToTimestamp = t.timeToTimestamp), + this.insertRequestChain(0, { + preRequest(t, e, a, i) { + if (this.timeToTimestamp && e.data) + for (let t in e.data) e.data[t] instanceof Date && (e.data[t] = e.data[t].getTime()); + return !0; + }, + }); + } + addRequestChain(t) { + this.requestChain.push(t); + } + insertRequestChain(t, e) { + this.requestChain.splice(t, 0, e); + } + removeRequestChain(t) { + this.requestChain.splice(t, 1); + } + requestCaptchaData() { + const t = {}; + (t.headers = this.requestHeaders || {}), + (t.data = {}), + (t.headers['Content-Type'] = 'application/json;charset=UTF-8'), + (t.method = 'POST'), + (t.url = this.requestCaptchaDataUrl), + this._preRequest('requestCaptchaData', t); + return this.doSendRequest(t).then((e) => (this._postRequest('requestCaptchaData', t, e), e)); + } + doSendRequest(t) { + if (t.headers) + for (const e in t.headers) + if (t.headers[e].indexOf('application/json') > -1) { + 'string' != typeof t.data && (t.data = JSON.stringify(t.data)); + break; + } + return ((e = t), + new Promise(function (t, a) { + var i = new XMLHttpRequest(); + if ((i.open(e.method || 'GET', e.url), e.headers)) + for (const t in e.headers) e.headers.hasOwnProperty(t) && i.setRequestHeader(t, e.headers[t]); + (i.onreadystatechange = function () { + if (i.readyState === XMLHttpRequest.DONE) + if (i.status >= 200 && i.status <= 500) { + const e = i.getResponseHeader('Content-Type'); + e && -1 !== e.indexOf('application/json') ? t(JSON.parse(i.responseText)) : t(i.responseText); + } else a(new Error('Request failed with status: ' + i.status)); + }), + (i.onerror = function () { + a(new Error('Network Error')); + }), + i.send(e.data); + })).then((t) => { + try { + return JSON.parse(t); + } catch (e) { + return t; + } + }); + var e; + } + _preRequest(t, e, a, i) { + for (let r = 0; r < this.requestChain.length; r++) { + const n = this.requestChain[r]; + if (n.preRequest && !n.preRequest(t, e, this, a, i)) break; + } + } + _postRequest(t, e, a, i, r) { + for (let n = 0; n < this.requestChain.length; n++) { + const c = this.requestChain[n]; + if (c.postRequest && !c.postRequest(t, e, a, this, i, r)) break; + } + } + validCaptcha(t, e, a, i) { + const r = { id: t, data: e }; + let n = {}; + (n.headers = this.requestHeaders || {}), + (n.data = r), + (n.headers['Content-Type'] = 'application/json;charset=UTF-8'), + (n.method = 'POST'), + (n.url = this.validCaptchaUrl), + this._preRequest('validCaptcha', n, a, i); + return this.doSendRequest(n) + .then((t) => (this._postRequest('validCaptcha', n, t, a, i), t)) + .then((t) => { + if (200 == t.code) { + const r = (e.endSlidingTime - e.startSlidingTime) / 1e3; + a.showTips(`验证成功,耗时${r}秒`, 1, () => this.validSuccess(t, a, i)); + } else { + let e = '验证失败,请重新尝试!'; + t.code && 4001 != t.code && (e = '验证码被黑洞吸走了!'), + a.showTips(e, 0, () => this.validFail(t, a, i)); + } + }) + .catch((t) => { + let e = a.styleConfig.i18n.tips_error; + t.code && + 200 != t.code && + (4001 != res.code && (e = a.styleConfig.i18n.tips_4001), + a.showTips(e, 0, () => this.validFail(res, a, i))); + }); + } + validSuccess(t, e, a) { + (window.currentCaptchaRes = t), a.destroyWindow(); + } + validFail(t, e, a) { + a.reloadCaptcha(); + } + } + (window.TAC = class { + constructor(t, e) { + (this.config = (function (t) { + return t instanceof b ? t : new b(t); + })(t)), + this.config.btnRefreshFun && (this.btnRefreshFun = this.config.btnRefreshFun), + this.config.btnCloseFun && (this.btnCloseFun = this.config.btnCloseFun), + (this.style = (function (t) { + return ( + t || (t = {}), + t.btnUrl || + (t.btnUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIwAAABkCAYAAABU19jRAAAJcUlEQVR4nO2d63MT1xmHf9rV6mr5fgNMuSW+ENsY8N0EE2BMhinJNB8y/dD2Qz/0v+gMf0w/JHTKNJAhICwbsA02TpNAHEMgQIwNBSEb8F2rvXTeY1kjYyA+TmVJmfeZ8YiRWa9299E57/mdI63Dtm3E+RjAKTDMaj4F8AU9uyzMCQBn+EQxb+EjAF+RMH8AcJrPFLMGvCSMzWeKWSN/I2GiAFx8xpi1oPBZYiTQWRhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGChaGkYKFYaRgYRgpWBhGCiefrtShGwZiup74+4qqwu12Z/W7lIVJEfN6FDfv3sPXfYOIRRfpm1UQKC7EkQ+PYFtRcdZKw8KkiLsPJ/CfgSFcH7yOxWhU7MSluYQoR44fxdaCoqyUhoVJEfZ8FN99c1N0Sx6PR+zEMAz0XAgBNtB14hi25OXDkWXHxUVvinA4ln6ScTqdsGwbvRd7EPwyiEcvXyDbvpyHhUkRaq4fe/c3wEWSWFZiJySNYZroCYYQPHsBY1OTWSWNevLkyb/TYwa8lt8UAb8ftluDW9UwPj4hDs0Rb3JUVRXd09j9nwELKKgoR4HXlw2Hb3INkyK8mob9NdUwLROq4sCVKwMrdqRpGkzTFN0TaWR2HcKu0rKMr2lYmBTi1jS01dUt7UBx4PKlfvHP5JaGuqseIY0DjmOHsKukNKOPiYVJMU5VRXt9PSwboO+fvHJ5QEiiKEvlIz3S86HuHiiqAhw9iJ0lpRnb0rAwG4CqKHh/Tz0UhwOWaWGg/5oofEkmJLU4wfPdQia765CQJhNHJCzMBkEtSVtdLRw2YNo2hgaGEDMMMWpahrwJBUMUCkM9djgjE2EWZgOhFqW5rlbMKdm2heHBYUT1mCiAEW9pKKfpPh8Sj5mYCLMwG4zLqWJfTZWQgL5S++uhYURjBrR4S0MtUSYnwixMGvBoGvZUV4quh0S4Pjgsaho1XtOIcM8wxJCb+qmu33dljDS/CWEeTb/E/Pw89EUdebkBVBQWrnnbWVjQoMAtsT9asGDQhf8VUbnX5UJ9VaVoZahVuXZ1cMXoiaSJxWIiEab/dPj4UXFczjRrk/VJ70/hp/jhuxF89o9TGP1+FH6fD9OxGHw5Pnicb34/PJ2dweitu7hwLojvb47A9rhQmJeXGLm8iQeP/4uRH27h88/+iZhhYs40UFZQsK7XrqkqigvyYbk18VrHH74+EX74YAzRqI66mupE15UmzKwW5kEkgtFvRxA8ex7hJ2HMzczgzu0f8fjxExRt2YzcgB9udfUJjuo6Tv/7HE6f+pe4GHd//AkwLRhuDeXFRW+U5v7EI4yMjKI3GMLt0Tt4cO8BAoEcWJoTZYXrl6asqBC6U0GOy42HY+MrZi1JmoWFRZQW5sNyuVBeUpxOabJ7aiASjiB4/iKmnj+H5loaacwvLOL2jRF4AjnY8dc/I/DKbTdoSHvr8SO8DD/DzPSMWHrg1JwYvHZdpK2NVZWU26/aF3VDTyLP0N/bh4mJR3C7XZiZnRVdht/nx7u7tsOzzg5qORFWHAocigO9vX2Jronwej24cXMEbq8XrfW169rH/4usnq02o1FEo9FEE47luN22sTAzC0OPrd7ItnHn9h0MDg3D6/WKbZdHJqYRg26ar92XDgvD39zA2Ng4VKdTbEf7mpmeRX/fAPRfeRch+luNNTXICeSu+h3ti7okUzdgp3luO6uFUTUN9lLmnniOCkdKVnML8uB0r76rD72Di4qL4NI0IUnydpZlw/WmGsY00bRvDzZvKhfFKLAU9VOG8v7BdijW+i8kLX649yyMz0+fwVQksur3NILyejzw5efCoaT3kmW1MN68AMq2bBIXXtd18WMZBt6r242DBzvgda3uWhQ4xNzOkeNdohZYXFjA4vwCfD4/Sio2i9bjdeSoGirKylFYXirykehiFHpUR2FJCbZu+x1yXlMrrQWSZWwygv6Ll3DxXBCX+66u6I7o2DRFRWtbM1o62xNdb7rI7lGSqqBs+zZMTj4XLYY/x49t7+zABx8eReWO7ciLL41ctZmqoqRiE/x+P6amp5FbkI9jx7tw+GgncqmbesPuPAEfduzcgenZOTg0FaWby/GXP/0RdZXvrOvlkyzjzyfR81UIoQs9IpRJniqglszt0tDc1oS9bc2o37lTLMhKI2bW35HtRXQRs3MLmH/xUrzzVb8HJQUFyHX/crJCQ+JwOALFqaKspGjNRWtkbg5zc7PQXC5szl/f6Ig6MFqiSavuqHCmumuFLIYBt+ZEY0sTGtua0VBTJQK/NKPzLfzSQEKWL4NiiG5a1gpZzPhMdnNrE/a3N2NPVaUI+jIAnacGNhiShdbx9pzrFgunSA4tqeUQRbuqoLW9BQ0tjSINzhBZBCzMBvPzVAS950KiG6KWJVkWGnXRELrjQBtqG/eioTqzZAELs3FQy3Iv/BR9wUtiUtGOr+tNhoptGt1V7atD4+4aEehlGizMBnH/WRj9wcuiG7LjI7Vllm8d3nnoAKoaakXq+0tzWumChUkxdlyWge4rYt0uzRMpSck01SzUDR3s7MC7e2pFRqSmOZx7GyxMCrESLcsldAd7oCgrEx6xrldRRM1SvbceHfV1K0K7TISFSREx28L41KRIcGmdruOVz82KBFd1oqWjBe/tb0ArLd3McFnAwqSOiclJ9JwP4fLFXtEtJXdDywluU2uTGDpTgZupNcur8GerU8R0eBJDV6+LRVbJLYdIcF2aSHD3tzaL9b20zjdbYGFShB0z4HY6V9QtFNLRXFATxf2U4FZXZkLcLwULkyJoaUXMNMV6HbyS4O6jicQMS3DXCguTInJKC9HU0YoPOg8k1uy0t7eivnmfSHB9WSgLwZOPKcKwLcT0GL69cxe3b46KoK6+ZS92V2zNyAR3jfBsdaox6LPSpiVyf/rEo/rq11JlFzxbnWoomEMW5CtrhWsYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgoWhpGChWGkYGEYKVgYRgr6qGx6b4/BZBXUwnzCl4xZI5844g3MCQBn+Kwxb+EjAGcdST3SxwBO8RljXsOnAL4AgP8BXnVIgIvemwsAAAAASUVORK5CYII='), + t.moveTrackMaskBgColor || + t.moveTrackMaskBorderColor || + ((t.moveTrackMaskBgColor = '#89d2ff'), (t.moveTrackMaskBorderColor = '#0298f8')), + t + ); + })(e)); + } + init() { + return ( + this.destroyWindow(), + this.config.domBindEl.append( + '\n
\n
\n
\n loading\n
\n \x3c!-- 底部 --\x3e\n
\n \n
\n
\n
\n
\n ', + ), + (this.domTemplate = this.config.domBindEl.find('#tianai-captcha-parent')), + r(this.domTemplate), + this.loadStyle(), + this.config.domBindEl.find('#tianai-captcha-slider-refresh-btn').click((t) => { + this.btnRefreshFun(t, this); + }), + this.config.domBindEl.find('#tianai-captcha-slider-close-btn').click((t) => { + this.btnCloseFun(t, this); + }), + this.reloadCaptcha(), + this + ); + } + btnRefreshFun(t, e) { + e.reloadCaptcha(); + } + btnCloseFun(t, e) { + e.destroyWindow(); + } + reloadCaptcha() { + this.showLoading(), + this.destroyCaptcha(() => { + this.createCaptcha(); + }); + } + showLoading() { + this.config.domBindEl.find('#tianai-captcha-loading').css('display', 'block'); + } + closeLoading() { + this.config.domBindEl.find('#tianai-captcha-loading').css('display', 'none'); + } + loadStyle() { + const t = this.style.bgUrl, + e = this.style.logoUrl; + t && this.config.domBindEl.find('#tianai-captcha-bg-img').css('background-image', 'url(' + t + ')'), + e && '' !== e + ? this.config.domBindEl.find('#tianai-captcha-logo').attr('src', e) + : null === e && this.config.domBindEl.find('#tianai-captcha-logo').css('display', 'none'); + } + destroyWindow() { + (window.currentCaptcha = void 0), this.domTemplate && this.domTemplate.remove(); + } + openCaptcha() { + setTimeout(() => { + window.currentCaptcha.el.css('transform', 'translateX(0)'); + }, 10); + } + createCaptcha() { + this.config.requestCaptchaData().then((t) => { + this.closeLoading(); + const e = (function (t, e) { + switch (t) { + case 'SLIDER': + return new f('#tianai-captcha-box', e); + case 'ROTATE': + return new m('#tianai-captcha-box', e); + case 'CONCAT': + return new v('#tianai-captcha-box', e); + case 'WORD_IMAGE_CLICK': + return new w('#tianai-captcha-box', e); + default: + return null; + } + })(t.captcha.type, this.style); + if (null == e) throw new Error('[TAC] 未知的验证码类型[' + t.captcha.type + ']'); + e.init(t, (t, e) => { + const a = e.currentCaptchaData, + i = { + bgImageWidth: a.bgImageWidth, + bgImageHeight: a.bgImageHeight, + sliderImageWidth: a.sliderImageWidth, + sliderImageHeight: a.sliderImageHeight, + startSlidingTime: a.startTime, + endSlidingTime: a.stopTime, + trackList: a.trackArr, + }; + ('ROTATE_DEGREE' !== e.type && 'ROTATE' !== e.type) || (i.bgImageWidth = e.currentCaptchaData.end); + const r = e.currentCaptchaData.currentCaptchaId; + (e.currentCaptchaData = void 0), this.config.validCaptcha(r, i, e, this); + }), + this.openCaptcha(); + }); + } + destroyCaptcha(t) { + window.currentCaptcha + ? (window.currentCaptcha.el.css('transform', 'translateX(300px)'), + setTimeout(() => { + window.currentCaptcha.destroy(), t && t(); + }, 500)) + : t(); + } + }), + (window.CaptchaConfig = b); + }, + }, + i = {}; + function r(t) { + var e = i[t]; + if (void 0 !== e) { + if (void 0 !== e.error) throw e.error; + return e.exports; + } + var n = (i[t] = { id: t, exports: {} }); + try { + var c = { id: t, module: n, factory: a[t], require: r }; + r.i.forEach(function (t) { + t(c); + }), + (n = c.module), + c.factory.call(n.exports, n, n.exports, c.require); + } catch (t) { + throw ((n.error = t), t); + } + return n.exports; + } + (r.m = a), + (r.c = i), + (r.i = []), + (r.hu = (t) => t + '.' + r.h() + '.hot-update.js'), + (r.miniCssF = (t) => {}), + (r.hmrF = () => 'main.' + r.h() + '.hot-update.json'), + (r.h = () => '64e77207d3e87617a00e'), + (r.g = (function () { + if ('object' == typeof globalThis) return globalThis; + try { + return this || new Function('return this')(); + } catch (t) { + if ('object' == typeof window) return window; + } + })()), + (r.o = (t, e) => Object.prototype.hasOwnProperty.call(t, e)), + (t = {}), + (e = 'webpack-demo:'), + (r.l = (a, i, n, c) => { + if (t[a]) t[a].push(i); + else { + var s, o; + if (void 0 !== n) + for (var d = document.getElementsByTagName('script'), h = 0; h < d.length; h++) { + var l = d[h]; + if (l.getAttribute('src') == a || l.getAttribute('data-webpack') == e + n) { + s = l; + break; + } + } + s || + ((o = !0), + ((s = document.createElement('script')).charset = 'utf-8'), + (s.timeout = 120), + r.nc && s.setAttribute('nonce', r.nc), + s.setAttribute('data-webpack', e + n), + (s.src = a)), + (t[a] = [i]); + var p = (e, i) => { + (s.onerror = s.onload = null), clearTimeout(u); + var r = t[a]; + if ((delete t[a], s.parentNode && s.parentNode.removeChild(s), r && r.forEach((t) => t(i)), e)) return e(i); + }, + u = setTimeout(p.bind(null, void 0, { type: 'timeout', target: s }), 12e4); + (s.onerror = p.bind(null, s.onerror)), (s.onload = p.bind(null, s.onload)), o && document.head.appendChild(s); + } + }), + (() => { + var t, + e, + a, + i = {}, + n = r.c, + c = [], + s = [], + o = 'idle', + d = 0, + h = []; + function l(t) { + o = t; + for (var e = [], a = 0; a < s.length; a++) e[a] = s[a].call(null, t); + return Promise.all(e); + } + function p() { + 0 == --d && + l('ready').then(function () { + if (0 === d) { + var t = h; + h = []; + for (var e = 0; e < t.length; e++) t[e](); + } + }); + } + function u(t) { + if ('idle' !== o) throw new Error('check() is only allowed in idle status'); + return l('check') + .then(r.hmrM) + .then(function (a) { + return a + ? l('prepare').then(function () { + var i = []; + return ( + (e = []), + Promise.all( + Object.keys(r.hmrC).reduce(function (t, n) { + return r.hmrC[n](a.c, a.r, a.m, t, e, i), t; + }, []), + ).then(function () { + return ( + (e = function () { + return t + ? g(t) + : l('ready').then(function () { + return i; + }); + }), + 0 === d + ? e() + : new Promise(function (t) { + h.push(function () { + t(e()); + }); + }) + ); + var e; + }) + ); + }) + : l(f() ? 'ready' : 'idle').then(function () { + return null; + }); + }); + } + function A(t) { + return 'ready' !== o + ? Promise.resolve().then(function () { + throw new Error('apply() is only allowed in ready status (state: ' + o + ')'); + }) + : g(t); + } + function g(t) { + (t = t || {}), f(); + var i = e.map(function (e) { + return e(t); + }); + e = void 0; + var r = i + .map(function (t) { + return t.error; + }) + .filter(Boolean); + if (r.length > 0) + return l('abort').then(function () { + throw r[0]; + }); + var n = l('dispose'); + i.forEach(function (t) { + t.dispose && t.dispose(); + }); + var c, + s = l('apply'), + o = function (t) { + c || (c = t); + }, + d = []; + return ( + i.forEach(function (t) { + if (t.apply) { + var e = t.apply(o); + if (e) for (var a = 0; a < e.length; a++) d.push(e[a]); + } + }), + Promise.all([n, s]).then(function () { + return c + ? l('fail').then(function () { + throw c; + }) + : a + ? g(t).then(function (t) { + return ( + d.forEach(function (e) { + t.indexOf(e) < 0 && t.push(e); + }), + t + ); + }) + : l('idle').then(function () { + return d; + }); + }) + ); + } + function f() { + if (a) + return ( + e || (e = []), + Object.keys(r.hmrI).forEach(function (t) { + a.forEach(function (a) { + r.hmrI[t](a, e); + }); + }), + (a = void 0), + !0 + ); + } + (r.hmrD = i), + r.i.push(function (h) { + var g, + f, + m, + v, + C = h.module, + w = (function (e, a) { + var i = n[a]; + if (!i) return e; + var r = function (r) { + if (i.hot.active) { + if (n[r]) { + var s = n[r].parents; + -1 === s.indexOf(a) && s.push(a); + } else (c = [a]), (t = r); + -1 === i.children.indexOf(r) && i.children.push(r); + } else c = []; + return e(r); + }, + s = function (t) { + return { + configurable: !0, + enumerable: !0, + get: function () { + return e[t]; + }, + set: function (a) { + e[t] = a; + }, + }; + }; + for (var h in e) + Object.prototype.hasOwnProperty.call(e, h) && 'e' !== h && Object.defineProperty(r, h, s(h)); + return ( + (r.e = function (t) { + return (function (t) { + switch (o) { + case 'ready': + l('prepare'); + case 'prepare': + return d++, t.then(p, p), t; + default: + return t; + } + })(e.e(t)); + }), + r + ); + })(h.require, h.id); + (C.hot = + ((g = h.id), + (f = C), + (v = { + _acceptedDependencies: {}, + _acceptedErrorHandlers: {}, + _declinedDependencies: {}, + _selfAccepted: !1, + _selfDeclined: !1, + _selfInvalidated: !1, + _disposeHandlers: [], + _main: (m = t !== g), + _requireSelf: function () { + (c = f.parents.slice()), (t = m ? void 0 : g), r(g); + }, + active: !0, + accept: function (t, e, a) { + if (void 0 === t) v._selfAccepted = !0; + else if ('function' == typeof t) v._selfAccepted = t; + else if ('object' == typeof t && null !== t) + for (var i = 0; i < t.length; i++) + (v._acceptedDependencies[t[i]] = e || function () {}), (v._acceptedErrorHandlers[t[i]] = a); + else (v._acceptedDependencies[t] = e || function () {}), (v._acceptedErrorHandlers[t] = a); + }, + decline: function (t) { + if (void 0 === t) v._selfDeclined = !0; + else if ('object' == typeof t && null !== t) + for (var e = 0; e < t.length; e++) v._declinedDependencies[t[e]] = !0; + else v._declinedDependencies[t] = !0; + }, + dispose: function (t) { + v._disposeHandlers.push(t); + }, + addDisposeHandler: function (t) { + v._disposeHandlers.push(t); + }, + removeDisposeHandler: function (t) { + var e = v._disposeHandlers.indexOf(t); + e >= 0 && v._disposeHandlers.splice(e, 1); + }, + invalidate: function () { + switch (((this._selfInvalidated = !0), o)) { + case 'idle': + (e = []), + Object.keys(r.hmrI).forEach(function (t) { + r.hmrI[t](g, e); + }), + l('ready'); + break; + case 'ready': + Object.keys(r.hmrI).forEach(function (t) { + r.hmrI[t](g, e); + }); + break; + case 'prepare': + case 'check': + case 'dispose': + case 'apply': + (a = a || []).push(g); + } + }, + check: u, + apply: A, + status: function (t) { + if (!t) return o; + s.push(t); + }, + addStatusHandler: function (t) { + s.push(t); + }, + removeStatusHandler: function (t) { + var e = s.indexOf(t); + e >= 0 && s.splice(e, 1); + }, + data: i[g], + }), + (t = void 0), + v)), + (C.parents = c), + (C.children = []), + (c = []), + (h.require = w); + }), + (r.hmrC = {}), + (r.hmrI = {}); + })(), + (() => { + var t; + r.g.importScripts && (t = r.g.location + ''); + var e = r.g.document; + if (!t && e && (e.currentScript && (t = e.currentScript.src), !t)) { + var a = e.getElementsByTagName('script'); + if (a.length) for (var i = a.length - 1; i > -1 && !t; ) t = a[i--].src; + } + if (!t) throw new Error('Automatic publicPath is not supported in this browser'); + (t = t + .replace(/#.*$/, '') + .replace(/\?.*$/, '') + .replace(/\/[^\/]+$/, '/')), + (r.p = t); + })(), + (() => { + if ('undefined' != typeof document) { + var t = (t, e, a, i, r) => { + var n = document.createElement('link'); + (n.rel = 'stylesheet'), (n.type = 'text/css'); + return ( + (n.onerror = n.onload = (a) => { + if (((n.onerror = n.onload = null), 'load' === a.type)) i(); + else { + var c = a && ('load' === a.type ? 'missing' : a.type), + s = (a && a.target && a.target.href) || e, + o = new Error('Loading CSS chunk ' + t + ' failed.\n(' + s + ')'); + (o.code = 'CSS_CHUNK_LOAD_FAILED'), + (o.type = c), + (o.request = s), + n.parentNode && n.parentNode.removeChild(n), + r(o); + } + }), + (n.href = e), + a ? a.parentNode.insertBefore(n, a.nextSibling) : document.head.appendChild(n), + n + ); + }, + e = (t, e) => { + for (var a = document.getElementsByTagName('link'), i = 0; i < a.length; i++) { + var r = (c = a[i]).getAttribute('data-href') || c.getAttribute('href'); + if ('stylesheet' === c.rel && (r === t || r === e)) return c; + } + var n = document.getElementsByTagName('style'); + for (i = 0; i < n.length; i++) { + var c; + if ((r = (c = n[i]).getAttribute('data-href')) === t || r === e) return c; + } + }, + a = [], + i = [], + n = (t) => ({ + dispose: () => { + for (var t = 0; t < a.length; t++) { + var e = a[t]; + e.parentNode && e.parentNode.removeChild(e); + } + a.length = 0; + }, + apply: () => { + for (var t = 0; t < i.length; t++) i[t].rel = 'stylesheet'; + i.length = 0; + }, + }); + r.hmrC.miniCss = (c, s, o, d, h, l) => { + h.push(n), + c.forEach((n) => { + var c = r.miniCssF(n), + s = r.p + c, + o = e(c, s); + o && + d.push( + new Promise((e, r) => { + var c = t( + n, + s, + o, + () => { + (c.as = 'style'), (c.rel = 'preload'), e(); + }, + r, + ); + a.push(o), i.push(c); + }), + ); + }); + }; + } + })(), + (() => { + var t, + e, + a, + i, + n, + c = (r.hmrS_jsonp = r.hmrS_jsonp || { 179: 0 }), + s = {}; + function o(e, a) { + return ( + (t = a), + new Promise((t, a) => { + s[e] = t; + var i = r.p + r.hu(e), + n = new Error(); + r.l(i, (t) => { + if (s[e]) { + s[e] = void 0; + var i = t && ('load' === t.type ? 'missing' : t.type), + r = t && t.target && t.target.src; + (n.message = 'Loading hot update chunk ' + e + ' failed.\n(' + i + ': ' + r + ')'), + (n.name = 'ChunkLoadError'), + (n.type = i), + (n.request = r), + a(n); + } + }); + }) + ); + } + function d(t) { + function s(t) { + for ( + var e = [t], + a = {}, + i = e.map(function (t) { + return { chain: [t], id: t }; + }); + i.length > 0; + + ) { + var n = i.pop(), + c = n.id, + s = n.chain, + d = r.c[c]; + if (d && (!d.hot._selfAccepted || d.hot._selfInvalidated)) { + if (d.hot._selfDeclined) return { type: 'self-declined', chain: s, moduleId: c }; + if (d.hot._main) return { type: 'unaccepted', chain: s, moduleId: c }; + for (var h = 0; h < d.parents.length; h++) { + var l = d.parents[h], + p = r.c[l]; + if (p) { + if (p.hot._declinedDependencies[c]) + return { type: 'declined', chain: s.concat([l]), moduleId: c, parentId: l }; + -1 === e.indexOf(l) && + (p.hot._acceptedDependencies[c] + ? (a[l] || (a[l] = []), o(a[l], [c])) + : (delete a[l], e.push(l), i.push({ chain: s.concat([l]), id: l }))); + } + } + } + } + return { type: 'accepted', moduleId: t, outdatedModules: e, outdatedDependencies: a }; + } + function o(t, e) { + for (var a = 0; a < e.length; a++) { + var i = e[a]; + -1 === t.indexOf(i) && t.push(i); + } + } + r.f && delete r.f.jsonpHmr, (e = void 0); + var d = {}, + h = [], + l = {}, + p = function (t) {}; + for (var u in a) + if (r.o(a, u)) { + var A, + g = a[u], + f = !1, + m = !1, + v = !1, + C = ''; + switch ( + ((A = g ? s(u) : { type: 'disposed', moduleId: u }).chain && + (C = '\nUpdate propagation: ' + A.chain.join(' -> ')), + A.type) + ) { + case 'self-declined': + t.onDeclined && t.onDeclined(A), + t.ignoreDeclined || (f = new Error('Aborted because of self decline: ' + A.moduleId + C)); + break; + case 'declined': + t.onDeclined && t.onDeclined(A), + t.ignoreDeclined || + (f = new Error('Aborted because of declined dependency: ' + A.moduleId + ' in ' + A.parentId + C)); + break; + case 'unaccepted': + t.onUnaccepted && t.onUnaccepted(A), + t.ignoreUnaccepted || (f = new Error('Aborted because ' + u + ' is not accepted' + C)); + break; + case 'accepted': + t.onAccepted && t.onAccepted(A), (m = !0); + break; + case 'disposed': + t.onDisposed && t.onDisposed(A), (v = !0); + break; + default: + throw new Error('Unexception type ' + A.type); + } + if (f) return { error: f }; + if (m) + for (u in ((l[u] = g), o(h, A.outdatedModules), A.outdatedDependencies)) + r.o(A.outdatedDependencies, u) && (d[u] || (d[u] = []), o(d[u], A.outdatedDependencies[u])); + v && (o(h, [A.moduleId]), (l[u] = p)); + } + a = void 0; + for (var w, b = [], D = 0; D < h.length; D++) { + var k = h[D], + E = r.c[k]; + E && + (E.hot._selfAccepted || E.hot._main) && + l[k] !== p && + !E.hot._selfInvalidated && + b.push({ module: k, require: E.hot._requireSelf, errorHandler: E.hot._selfAccepted }); + } + return { + dispose: function () { + var t; + i.forEach(function (t) { + delete c[t]; + }), + (i = void 0); + for (var e, a = h.slice(); a.length > 0; ) { + var n = a.pop(), + s = r.c[n]; + if (s) { + var o = {}, + l = s.hot._disposeHandlers; + for (D = 0; D < l.length; D++) l[D].call(null, o); + for (r.hmrD[n] = o, s.hot.active = !1, delete r.c[n], delete d[n], D = 0; D < s.children.length; D++) { + var p = r.c[s.children[D]]; + p && (t = p.parents.indexOf(n)) >= 0 && p.parents.splice(t, 1); + } + } + } + for (var u in d) + if (r.o(d, u) && (s = r.c[u])) + for (w = d[u], D = 0; D < w.length; D++) + (e = w[D]), (t = s.children.indexOf(e)) >= 0 && s.children.splice(t, 1); + }, + apply: function (e) { + for (var a in l) r.o(l, a) && (r.m[a] = l[a]); + for (var i = 0; i < n.length; i++) n[i](r); + for (var c in d) + if (r.o(d, c)) { + var s = r.c[c]; + if (s) { + w = d[c]; + for (var o = [], p = [], u = [], A = 0; A < w.length; A++) { + var g = w[A], + f = s.hot._acceptedDependencies[g], + m = s.hot._acceptedErrorHandlers[g]; + if (f) { + if (-1 !== o.indexOf(f)) continue; + o.push(f), p.push(m), u.push(g); + } + } + for (var v = 0; v < o.length; v++) + try { + o[v].call(null, w); + } catch (a) { + if ('function' == typeof p[v]) + try { + p[v](a, { moduleId: c, dependencyId: u[v] }); + } catch (i) { + t.onErrored && + t.onErrored({ + type: 'accept-error-handler-errored', + moduleId: c, + dependencyId: u[v], + error: i, + originalError: a, + }), + t.ignoreErrored || (e(i), e(a)); + } + else + t.onErrored && + t.onErrored({ type: 'accept-errored', moduleId: c, dependencyId: u[v], error: a }), + t.ignoreErrored || e(a); + } + } + } + for (var C = 0; C < b.length; C++) { + var D = b[C], + k = D.module; + try { + D.require(k); + } catch (a) { + if ('function' == typeof D.errorHandler) + try { + D.errorHandler(a, { moduleId: k, module: r.c[k] }); + } catch (i) { + t.onErrored && + t.onErrored({ + type: 'self-accept-error-handler-errored', + moduleId: k, + error: i, + originalError: a, + }), + t.ignoreErrored || (e(i), e(a)); + } + else + t.onErrored && t.onErrored({ type: 'self-accept-errored', moduleId: k, error: a }), + t.ignoreErrored || e(a); + } + } + return h; + }, + }; + } + (self.webpackHotUpdatewebpack_demo = (e, i, c) => { + for (var o in i) r.o(i, o) && ((a[o] = i[o]), t && t.push(o)); + c && n.push(c), s[e] && (s[e](), (s[e] = void 0)); + }), + (r.hmrI.jsonp = function (t, e) { + a || ((a = {}), (n = []), (i = []), e.push(d)), r.o(a, t) || (a[t] = r.m[t]); + }), + (r.hmrC.jsonp = function (t, s, h, l, p, u) { + p.push(d), + (e = {}), + (i = s), + (a = h.reduce(function (t, e) { + return (t[e] = !1), t; + }, {})), + (n = []), + t.forEach(function (t) { + r.o(c, t) && void 0 !== c[t] ? (l.push(o(t, u)), (e[t] = !0)) : (e[t] = !1); + }), + r.f && + (r.f.jsonpHmr = function (t, a) { + e && r.o(e, t) && !e[t] && (a.push(o(t)), (e[t] = !0)); + }); + }), + (r.hmrM = () => { + if ('undefined' == typeof fetch) throw new Error('No browser support: need fetch API'); + return fetch(r.p + r.hmrF()).then((t) => { + if (404 !== t.status) { + if (!t.ok) throw new Error('Failed to fetch update manifest ' + t.statusText); + return t.json(); + } + }); + }); + })(); + r(600); +})(); diff --git a/src/client/pages/less/AuthLayout.less b/src/client/pages/less/AuthLayout.less new file mode 100644 index 0000000..3511b24 --- /dev/null +++ b/src/client/pages/less/AuthLayout.less @@ -0,0 +1,153 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.codeBody { + position: absolute; + top: 0%; + left: 0%; + width: 100vw; + height: 100vh; + background-color: #00000020; + + .code { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + justify-content: center; + align-items: center; + width: calc(444 * 100vw / 1920); + height: calc(565 * 100vw / 1920); + background-color: #ffffff; + border-radius: calc(12 * 100vw / 1920); + + #captcha-box { + #tianai-captcha-parent { + #tianai-captcha-box { + height: 90%; + } + + .slider-bottom { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + height: 10%; + + .close-btn { + position: absolute; + top: 50%; + right: 10%; + transform: translate(-50%, -50%); + cursor: pointer; + width: 20px; + height: 20px; + background-image: url(../assets/icon.png); + background-repeat: no-repeat; + background-position: 0 -40px; + } + + .refresh-btn { + cursor: pointer; + width: 20px; + height: 20px; + background-image: url(../assets/icon.png); + background-repeat: no-repeat; + background-position: 0 -193px; + } + } + } + } + } +} + +.changePassword { + position: absolute; + top: 0%; + left: 0%; + width: 100vw; + height: 100vh; + background-color: #00000020; + .changePassword-body { + padding: calc(16 * 100vw / 1920) calc(20 * 100vw / 1920); + position: absolute; + top: 30%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(680 * 100vw / 1920); + height: calc(285 * 100vw / 1920); + background-color: #ffffff; + border-radius: calc(12 * 100vw / 1920); + .changePassword-title { + position: relative; + font-size: calc(20 * 100vw / 1920); + color: #333333; + margin-bottom: calc(20 * 100vw / 1920); + margin-left: calc(12 * 100vw / 1920); + font-weight: 600; + &::before{ + position: absolute; + top: 56%; + left: -2%; + transform: translate(-50%, -50%); + content: ' '; + display: inline-block; + width: calc(4 * 100vw / 1920); + height: calc(24 * 100vw / 1920); + background-color: #1890ff; + } + } + + .changePassword-form { + padding: calc(0 * 100vw / 1920) calc(48 * 100vw / 1920); + width: 100%; + .changePassword-form-item { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: calc(72 * 100vw / 1920); + margin-bottom: calc(8 * 100vw / 1920); + .text { + position: relative; + width: calc(120 * 100vw / 1920); + font-size: calc(16 * 100vw / 1920); + color: #333333; + font-weight: 600; + &::before{ + position: absolute; + top: 0%; + left: -6%; + transform: translate(-50%, -50%); + content: '*'; + display: inline-block; + width: calc(4 * 100vw / 1920); + height: calc(4 * 100vw / 1920); + color: #ff0004; + } + } + .statustext { + position: absolute; + bottom: calc(-4 * 100vw / 1920); + left: calc(100 * 100vw / 1920); + font-size: calc(12 * 100vw / 1920); + color: #ff0004; + font-weight: 500; + } + input { + height: calc(28 * 100vw / 1920); + } + } + .changePassword-form-button { + width: 100%; + display: flex; + justify-content: flex-end; + } + } + } +} diff --git a/src/client/settings/Authenticator.tsx b/src/client/settings/Authenticator.tsx new file mode 100644 index 0000000..d10d821 --- /dev/null +++ b/src/client/settings/Authenticator.tsx @@ -0,0 +1,107 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + ActionContextProvider, + SchemaComponent, + useAPIClient, + useActionContext, + useAsyncData, + useRequest, +} from '@nocobase/client'; +import { Card } from 'antd'; +import React, { useState } from 'react'; +import { authenticatorsSchema, createFormSchema } from './schemas/authenticators'; +import { Button, Dropdown } from 'antd'; +import { PlusOutlined, DownOutlined } from '@ant-design/icons'; +import { AuthTypeContext, AuthTypesContext, useAuthTypes } from './authType'; +import { useValuesFromOptions, Options } from './Options'; +import { useTranslation } from 'react-i18next'; +import { useAuthTranslation } from '../locale'; +import { Schema } from '@formily/react'; + +const useCloseAction = () => { + const { setVisible } = useActionContext(); + return { + async run() { + setVisible(false); + }, + }; +}; + +const AddNew = () => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [type, setType] = useState(''); + const types = useAuthTypes(); + const items = types.map((item) => ({ + ...item, + onClick: () => { + setVisible(true); + setType(item.value); + }, + })); + + return ( + + + + + + + + + ); +}; + +// Disable delete button when there is only one authenticator +const useCanNotDelete = () => { + const { data } = useAsyncData(); + // return data?.meta?.count === 1; + return false; +}; + +export const Authenticator = () => { + const { t } = useAuthTranslation(); + const [types, setTypes] = useState([]); + const api = useAPIClient(); + useRequest( + () => + api + .resource('authenticators') + .listTypes() + .then((res) => { + const types = res?.data?.data || []; + return types.map((type: { name: string; title?: string }) => ({ + key: type.name, + label: Schema.compile(type.title || type.name, { t }), + value: type.name, + })); + }), + { + onSuccess: (types) => { + setTypes(types); + }, + }, + ); + + return ( + + + + + + ); +}; diff --git a/src/client/settings/Options.tsx b/src/client/settings/Options.tsx new file mode 100644 index 0000000..e4535b2 --- /dev/null +++ b/src/client/settings/Options.tsx @@ -0,0 +1,54 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { observer, useForm } from '@formily/react'; +import { useActionContext, usePlugin, useRecord, useRequest } from '@nocobase/client'; +import { useEffect } from 'react'; +import AuthPlugin from '..'; + +export const useValuesFromOptions = (options) => { + const record = useRecord(); + const result = useRequest( + () => + Promise.resolve({ + data: { + ...record.options, + }, + }), + { + ...options, + manual: true, + }, + ); + const { run } = result; + const ctx = useActionContext(); + useEffect(() => { + if (ctx.visible) { + run(); + } + }, [ctx.visible, run]); + return result; +}; + +export const useAdminSettingsForm = (authType: string) => { + const plugin = usePlugin(AuthPlugin); + const auth = plugin.authTypes.get(authType); + return auth?.components?.AdminSettingsForm; +}; + +export const Options = observer( + () => { + const form = useForm(); + const record = useRecord(); + const Component = useAdminSettingsForm(form.values.authType || record.authType); + return Component ? : null; + }, + { displayName: 'Options' }, +); diff --git a/src/client/settings/authType.ts b/src/client/settings/authType.ts new file mode 100644 index 0000000..f6f193a --- /dev/null +++ b/src/client/settings/authType.ts @@ -0,0 +1,29 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createContext, useContext } from 'react'; + +export const AuthTypeContext = createContext<{ + type: string; +}>({ type: '' }); +AuthTypeContext.displayName = 'AuthTypeContext'; + +export const AuthTypesContext = createContext<{ + types: { + key: string; + label: string; + value: string; + }[]; +}>({ types: [] }); +AuthTypesContext.displayName = 'AuthTypesContext'; + +export const useAuthTypes = () => { + const { types } = useContext(AuthTypesContext); + return types; +}; diff --git a/src/client/settings/schemas/authenticators.ts b/src/client/settings/schemas/authenticators.ts new file mode 100644 index 0000000..b021a93 --- /dev/null +++ b/src/client/settings/schemas/authenticators.ts @@ -0,0 +1,421 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ISchema } from '@formily/react'; +import { uid } from '@formily/shared'; +import { i18n, useAPIClient, useActionContext, useRequest } from '@nocobase/client'; +import { message } from 'antd'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AuthTypeContext } from '../authType'; + +const collection = { + name: 'authenticators', + sortable: true, + fields: [ + { + name: 'id', + type: 'string', + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'name', + uiSchema: { + type: 'string', + title: '{{t("Auth UID")}}', + 'x-component': 'Input', + 'x-validator': (value: string) => { + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return i18n.t('a-z, A-Z, 0-9, _, -'); + } + return ''; + }, + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'authType', + uiSchema: { + type: 'string', + title: '{{t("Auth Type")}}', + 'x-component': 'Select', + dataSource: '{{ types }}', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'title', + uiSchema: { + type: 'string', + title: '{{t("Title")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'textarea', + type: 'string', + name: 'description', + uiSchema: { + type: 'string', + title: '{{t("Description")}}', + 'x-component': 'Input', + }, + }, + { + type: 'boolean', + name: 'enabled', + uiSchema: { + type: 'boolean', + title: '{{t("Enabled")}}', + 'x-component': 'Checkbox', + }, + }, + ], +}; + +export const createFormSchema: ISchema = { + type: 'object', + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + const ctx = useActionContext(); + const { type: authType } = useContext(AuthTypeContext); + return useRequest( + () => + Promise.resolve({ + data: { + name: `s_${uid()}`, + authType, + }, + }), + { ...options, refreshDeps: [ctx.visible] }, + ); + }, + }, + title: '{{t("Add new")}}', + properties: { + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + authType: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-component-props': { + options: '{{ types }}', + }, + }, + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + description: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + options: { + type: 'object', + 'x-component': 'Options', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateAction }}', + }, + }, + }, + }, + }, + }, + }, +}; + +export const authenticatorsSchema: ISchema = { + type: 'void', + name: 'authenticators', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'authenticators', + dragSort: true, + request: { + resource: 'authenticators', + action: 'list', + params: { + pageSize: 50, + sort: 'sort', + appends: [], + }, + }, + }, + 'x-component': 'CollectionProvider_deprecated', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + delete: { + type: 'void', + title: '{{t("Delete")}}', + 'x-component': 'Action', + 'x-component-props': { + icon: 'DeleteOutlined', + useAction: '{{ cm.useBulkDestroyAction }}', + confirm: { + title: "{{t('Delete')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + create: { + type: 'void', + title: '{{t("Add new")}}', + 'x-component': 'AddNew', + 'x-component-props': { + type: 'primary', + }, + }, + }, + }, + table: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + useAction() { + const api = useAPIClient(); + const { t } = useTranslation(); + return { + async move(from, to) { + await api.resource('authenticators').move({ + sourceId: from.id, + targetId: to.id, + }); + message.success(t('Saved successfully'), 0.2); + }, + }; + }, + }, + properties: { + id: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + id: { + type: 'number', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + name: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + name: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + authType: { + title: '{{t("Auth Type")}}', + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + authType: { + type: 'string', + 'x-component': 'Select', + 'x-read-pretty': true, + enum: '{{ types }}', + }, + }, + }, + title: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + description: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + description: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + enabled: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + enabled: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + actions: { + type: 'void', + title: '{{t("Actions")}}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + update: { + type: 'void', + title: '{{t("Configure")}}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{t("Configure")}}', + properties: { + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + authType: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-component-props': { + options: '{{ types }}', + }, + }, + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + description: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + options: { + type: 'object', + 'x-component': 'Options', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{cm.useDestroyAction}}', + }, + 'x-disabled': '{{ useCanNotDelete() }}', + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/client/useCreateActionProps.ts b/src/client/useCreateActionProps.ts deleted file mode 100644 index 867e75b..0000000 --- a/src/client/useCreateActionProps.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useForm } from '@formily/react'; -import { useActionContext, useCollection, useDataBlockRequest, useDataBlockResource } from '@nocobase/client'; -import { App as AntdApp } from 'antd'; - -export const useCreateActionProps = () => { - const { setVisible } = useActionContext(); - const { message } = AntdApp.useApp(); - const form = useForm(); - const resource = useDataBlockResource(); - const { runAsync } = useDataBlockRequest(); - const collection = useCollection(); - return { - type: 'primary', - async onClick() { - await form.submit(); - const values = form.values; - if (values[collection.filterTargetKey]) { - await resource.update({ - values, - filterByTk: values[collection.filterTargetKey], - }); - } else { - await resource.publicSubmit({ - values, - }); - } - await runAsync(); - message.success('Saved successfully!'); - setVisible(false); - }, - }; -}; diff --git a/src/index.ts b/src/index.ts index c683267..2ed7b8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,10 @@ -export * from './server'; -export { default } from './server'; -// +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { AuthModel, BasicAuth, default } from './server'; diff --git a/src/locale/en-US.json b/src/locale/en-US.json new file mode 100644 index 0000000..f6f77a6 --- /dev/null +++ b/src/locale/en-US.json @@ -0,0 +1,27 @@ +{ + "Auth Type": "Auth Type", + "Authenticators": "Authenticators", + "Authentication": "Authentication", + "Sign in via email": "Sign in via email", + "Sign in via password": "Sign in via password", + "Not allowed to sign up": "Not allowed to sign up", + "Allow to sign up": "Allow to sign up", + "The username or email is incorrect, please re-enter": "The username or email is incorrect, please re-enter", + "The password is incorrect, please re-enter": "The password is incorrect, please re-enter", + "Not a valid cellphone number, please re-enter": "Not a valid cellphone number, please re-enter", + "The phone number has been registered, please login directly": "The phone number has been registered, please login directly", + "The phone number is not registered, please register first": "The phone number is not registered, please register first", + "Please keep and enable at least one authenticator": "Please keep and enable at least one authenticator", + "Allow to sign in with": "Allow to sign in with", + "Please enter a valid username": "Please enter a valid username", + "Please enter a valid email": "Please enter a valid email", + "Please enter your username or email": "Please enter your username or email", + "Please enter a password": "Please enter a password", + "Username/Email": "Username/Email", + "Auth UID": "Auth UID", + "The authentication allows users to sign in via username or email.": "The authentication allows users to sign in via username or email.", + "No authentication methods available.": "No authentication methods available.", + "The password is inconsistent, please re-enter": "The password is inconsistent, please re-enter", + "Sign-in": "Sign-in", + "Password": "Password" +} diff --git a/src/locale/ko_KR.json b/src/locale/ko_KR.json new file mode 100644 index 0000000..d2e1a9f --- /dev/null +++ b/src/locale/ko_KR.json @@ -0,0 +1,26 @@ +{ + "Auth Type": "인증 유형", + "Authenticators": "인증기", + "Authentication": "인증", + "Sign in via email": "이메일로 로그인", + "Sign in via password": "비밀번호로 로그인", + "Not allowed to sign up": "가입할 수 없음", + "Allow to sign up": "가입 허용", + "The username or email is incorrect, please re-enter": "사용자 이름 또는 이메일이 잘못되었습니다. 다시 입력하세요.", + "The password is incorrect, please re-enter": "비밀번호가 잘못되었습니다. 다시 입력하세요.", + "Not a valid cellphone number, please re-enter": "유효하지 않은 휴대폰 번호입니다. 다시 입력하세요.", + "The phone number has been registered, please login directly": "전화번호가 이미 등록되어 있습니다. 직접 로그인하세요.", + "The phone number is not registered, please register first": "전화번호가 등록되어 있지 않습니다. 먼저 등록하세요.", + "Please keep and enable at least one authenticator": "최소한 하나의 인증기를 유지하고 활성화하세요.", + "Allow to sign in with": "다음으로 로그인 허용", + "Please enter a valid username": "유효한 사용자 이름을 입력하세요.", + "Please enter a valid email": "유효한 이메일을 입력하세요.", + "Please enter your username or email": "사용자 이름 또는 이메일을 입력하세요.", + "SMS": "SMS", + "Username/Email": "사용자 이름/이메일", + "Auth UID": "인증 UID", + "The authentication allows users to sign in via username or email.": "이 인증 방식을 사용하면 사용자가 사용자 이름 또는 이메일로 로그인할 수 있습니다.", + "No authentication methods available.": "사용 가능한 인증 방법이 없습니다.", + "The password is inconsistent, please re-enter": "비밀번호가 일치하지 않습니다. 다시 입력하세요.", + "Sign-in": "로그인" +} diff --git a/src/locale/zh-CN.json b/src/locale/zh-CN.json new file mode 100644 index 0000000..1e244da --- /dev/null +++ b/src/locale/zh-CN.json @@ -0,0 +1,27 @@ +{ + "Auth Type": "认证类型", + "Authenticators": "认证器", + "Authentication": "用户认证", + "Sign in via email": "邮箱登录", + "Sign in via password": "密码登录", + "Not allowed to sign up": "禁止注册", + "Allow to sign up": "允许注册", + "The username or email is incorrect, please re-enter": "用户名或邮箱有误,请重新输入", + "The password is incorrect, please re-enter": "密码有误,请重新输入", + "Not a valid cellphone number, please re-enter": "不是有效的手机号,请重新输入", + "The phone number has been registered, please login directly": "手机号已注册,请直接登录", + "The phone number is not registered, please register first": "手机号未注册,请先注册", + "Please keep and enable at least one authenticator": "请至少保留并启用一个认证器", + "Allow to sign in with": "允许使用以下方式登录", + "Please enter a valid username": "请输入有效的用户名", + "Please enter a valid email": "请输入有效的邮箱", + "Please enter your username or email": "请输入用户名或邮箱", + "Please enter a password": "请输入密码", + "Username/Email": "用户名/邮箱", + "Auth UID": "认证标识", + "The authentication allows users to sign in via username or email.": "该认证方式支持用户通过用户名或邮箱登录。", + "No authentication methods available.": "没有可用的认证方式。", + "The password is inconsistent, please re-enter": "密码不一致,请重新输入", + "Sign-in": "登录", + "Password": "密码" +} diff --git a/src/preset.ts b/src/preset.ts new file mode 100644 index 0000000..13828db --- /dev/null +++ b/src/preset.ts @@ -0,0 +1,15 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +// @ts-ignore +import { name } from '../package.json'; +export const presetAuthType = 'Lw/Phone/Password/Sms'; +export const presetAuthenticator = 'lewanyun'; + +export const namespace = name; diff --git a/src/server/__tests__/actions.test.ts b/src/server/__tests__/actions.test.ts new file mode 100644 index 0000000..c5f87ca --- /dev/null +++ b/src/server/__tests__/actions.test.ts @@ -0,0 +1,277 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import Database, { Repository } from '@nocobase/database'; +import { createMockServer, MockServer } from '@nocobase/test'; + +describe('actions', () => { + describe('authenticators', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + let agent; + + beforeAll(async () => { + app = await createMockServer({ + plugins: ['auth'], + }); + db = app.db; + repo = db.getRepository('authenticators'); + agent = app.agent(); + }); + + afterEach(async () => { + await repo.destroy({ + truncate: true, + }); + }); + + afterAll(async () => { + await app.destroy(); + }); + + it('should list authenticator types', async () => { + const res = await agent.resource('authenticators').listTypes(); + expect(res.body.data).toMatchObject([ + { + name: 'Email/Password', + }, + ]); + }); + + it('should return enabled authenticators with public options', async () => { + await repo.destroy({ + truncate: true, + }); + await repo.createMany({ + records: [ + { name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } }, + { name: 'test2', authType: 'testType' }, + ], + }); + const res = await agent.resource('authenticators').publicList(); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].name).toBe('test'); + }); + + it('should keep at least one authenticator', async () => { + await repo.createMany({ + records: [{ name: 'test', authType: 'testType', enabled: true }], + }); + const res = await agent.resource('authenticators').destroy(); + expect(res.statusCode).toBe(400); + expect(await repo.count()).toBe(1); + }); + + it('shoud enable at least one authenticator', async () => { + await repo.createMany({ + records: [{ name: 'test', authType: 'testType', enabled: true }], + }); + const res = await agent.resource('authenticators').update({ + filterByTk: 1, + values: { + enabled: false, + }, + }); + expect(res.statusCode).toBe(400); + expect(await repo.count()).toBe(1); + }); + }); + + describe('auth', () => { + let app: MockServer; + let db: Database; + let agent; + + beforeEach(async () => { + process.env.INIT_ROOT_EMAIL = 'test@nocobase.com'; + process.env.INT_ROOT_USERNAME = 'test'; + process.env.INIT_ROOT_PASSWORD = '123456'; + process.env.INIT_ROOT_NICKNAME = 'Test'; + app = await createMockServer({ + plugins: ['auth', 'users'], + }); + db = app.db; + agent = app.agent(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should check parameters when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({}); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter your username or email'); + }); + + it('should check user when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: 'no-exists@nocobase.com', + }); + expect(res.statusCode).toEqual(401); + expect(res.error.text).toBe('The username or email is incorrect, please re-enter'); + }); + + it('should check password when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: process.env.INIT_ROOT_EMAIL, + password: 'incorrect', + }); + expect(res.statusCode).toEqual(401); + expect(res.error.text).toBe('The password is incorrect, please re-enter'); + }); + + it('should sign in with password', async () => { + let res = await agent.resource('auth').check(); + expect(res.body.data.id).toBeUndefined(); + + res = await agent + .post('/auth:signIn') + .set({ 'X-Authenticator': 'basic' }) + .send({ + account: process.env.INIT_ROOT_USERNAME || process.env.INIT_ROOT_EMAIL, + password: process.env.INIT_ROOT_PASSWORD, + }); + expect(res.statusCode).toEqual(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + + res = await agent.get('/auth:check').set({ Authorization: `Bearer ${token}`, 'X-Authenticator': 'basic' }); + expect(res.body.data.id).toBeDefined(); + }); + + it('should disable sign up', async () => { + let res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'new', + password: 'new', + confirm_password: 'new', + }); + expect(res.statusCode).toEqual(200); + + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'basic', + }, + values: { + options: { + public: { + allowSignUp: false, + }, + }, + }, + }); + res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: process.env.INIT_ROOT_USERNAME, + password: process.env.INIT_ROOT_PASSWORD, + }); + expect(res.statusCode).toEqual(403); + }); + + it('should compitible with old api', async () => { + // Create a user without username + const userRepo = db.getRepository('users'); + const email = 'test2@nocobase.com'; + const password = '1234567'; + await userRepo.create({ + values: { + email, + password, + }, + }); + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: 'test@nocobase.com', + password: '123456', + }); + expect(res.statusCode).toEqual(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + }); + + it('should change password', async () => { + // Create a user without email + const userRepo = db.getRepository('users'); + const user = await userRepo.create({ + values: { + username: 'test', + password: '12345', + }, + }); + const userAgent = await agent.login(user); + + // Should check password consistency + const res = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '12345', + newPassword: '123456', + confirmPassword: '1234567', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('The password is inconsistent, please re-enter'); + + // Should check old password + const res1 = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '1', + newPassword: '123456', + confirmPassword: '123456', + }); + expect(res1.statusCode).toEqual(401); + expect(res1.error.text).toBe('The password is incorrect, please re-enter'); + + const res2 = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '12345', + newPassword: '123456', + confirmPassword: '123456', + }); + expect(res2.statusCode).toEqual(200); + + // Create a user without username + const user1 = await userRepo.create({ + values: { + email: 'test3@nocobase.com', + password: '12345', + }, + }); + const res3 = await agent.login(user1).post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '12345', + newPassword: '123456', + confirmPassword: '123456', + }); + expect(res3.statusCode).toEqual(200); + }); + + it('should check confirm password when signing up', async () => { + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'new', + password: 'new', + confirm_password: 'new1', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('The password is inconsistent, please re-enter'); + }); + + it('should check username when signing up', async () => { + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: '@@', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter a valid username'); + }); + + it('should check password when signing up', async () => { + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'new', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter a password'); + }); + }); +}); diff --git a/src/server/__tests__/auth-model.test.ts b/src/server/__tests__/auth-model.test.ts new file mode 100644 index 0000000..134972a --- /dev/null +++ b/src/server/__tests__/auth-model.test.ts @@ -0,0 +1,114 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { MockServer, createMockServer } from '@nocobase/test'; +import { AuthModel } from '../model/authenticator'; +import { Database, Repository } from '@nocobase/database'; + +describe('AuthModel', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + + beforeEach(async () => { + app = await createMockServer({ + plugins: ['auth', 'users'], + }); + db = app.db; + repo = db.getRepository('authenticators'); + }); + + afterEach(async () => { + await app.db.clean({ drop: true }); + await app.destroy(); + }); + + it('should new user', async () => { + const emitSpy = vi.spyOn(db, 'emitAsync'); + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + const user = await authenticator.newUser('uuid1', { + username: 'test', + }); + expect(emitSpy).toHaveBeenCalledWith('users.afterCreateWithAssociations', user, expect.any(Object)); + const res = await repo.findOne({ + filterByTk: authenticator.id, + appends: ['users'], + }); + expect(res.users.length).toBe(1); + expect(res.users[0].username).toBe('test'); + }); + + it('should new user without userValues', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + await authenticator.newUser('uuid1'); + const res = await repo.findOne({ + filterByTk: authenticator.id, + appends: ['users'], + }); + expect(res.users.length).toBe(1); + expect(res.users[0].nickname).toBe('uuid1'); + }); + + it('should find user', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + const user = await authenticator.newUser('uuid1', { + username: 'test', + }); + const res = await authenticator.findUser('uuid1'); + expect(res).toBeDefined(); + expect(res.id).toBe(user.id); + }); + + it('should find or create user', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + // find user + let user1 = await authenticator.findUser('uuid1'); + expect(user1).toBeUndefined(); + user1 = await authenticator.newUser('uuid1', { + username: 'test', + }); + const res1 = await authenticator.findOrCreateUser('uuid1', { + username: 'test1', + }); + expect(res1).toBeDefined(); + expect(res1.username).toBe('test'); + expect(res1.id).toBe(user1.id); + + // create user + let user2 = await authenticator.findUser('uuid2'); + expect(user2).toBeUndefined(); + user2 = await authenticator.findOrCreateUser('uuid2', { + username: 'test2', + }); + const res2 = await authenticator.findUser('uuid2'); + expect(res2).toBeDefined(); + expect(res2.username).toBe('test2'); + expect(res2.id).toBe(user2.id); + }); +}); diff --git a/src/server/__tests__/auth.test.ts b/src/server/__tests__/auth.test.ts new file mode 100644 index 0000000..c911251 --- /dev/null +++ b/src/server/__tests__/auth.test.ts @@ -0,0 +1,86 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { BaseAuth } from '@nocobase/auth'; +import { Database, Model } from '@nocobase/database'; +import { MockServer, createMockServer } from '@nocobase/test'; + +describe('auth', () => { + let auth: BaseAuth; + let app: MockServer; + let db: Database; + let user: Model; + + beforeEach(async () => { + app = await createMockServer({ + plugins: ['users', 'auth'], + }); + db = app.db; + + user = await db.getRepository('users').create({ + values: { + username: 'admin', + }, + }); + + const jwt = app.authManager.jwt; + auth = new BaseAuth({ + userCollection: db.getCollection('users'), + ctx: { + app, + getBearerToken() { + return jwt.sign({ userId: user.id }); + }, + cache: app.cache, + } as any, + } as any); + + await app.cache.reset(); + }); + + afterEach(async () => { + await app.cache.reset(); + await app.destroy(); + }); + + it('should get user from cache', async () => { + expect(await app.cache.get(auth.getCacheKey(user.id))).toBeUndefined(); + let userData = await auth.check(); + expect(userData).not.toBeNull(); + expect(await app.cache.get(auth.getCacheKey(user.id))).toBeDefined(); + userData = await auth.check(); + expect(userData).not.toBeNull(); + }); + + it('should update cache when user changed', async () => { + await auth.check(); + let cacheData = await app.cache.get(auth.getCacheKey(user.id)); + expect(cacheData['nickname']).toBeNull(); + await db.getRepository('users').update({ + values: { + nickname: 'admin', + }, + filterByTk: user.id, + }); + cacheData = await app.cache.get(auth.getCacheKey(user.id)); + console.log(cacheData); + expect(cacheData['nickname']).toBe('admin'); + }); + + it('should delete cache when user deleted', async () => { + await auth.check(); + let cacheData = await app.cache.get(auth.getCacheKey(user.id)); + expect(cacheData['nickname']).toBeNull(); + await db.getRepository('users').destroy({ + filterByTk: user.id, + }); + cacheData = await app.cache.get(auth.getCacheKey(user.id)); + expect(cacheData).toBeUndefined(); + }); +}); diff --git a/src/server/__tests__/storer.test.ts b/src/server/__tests__/storer.test.ts new file mode 100644 index 0000000..2e5ad65 --- /dev/null +++ b/src/server/__tests__/storer.test.ts @@ -0,0 +1,98 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Cache, CacheManager } from '@nocobase/cache'; +import { Storer } from '../storer'; + +class MockDB { + data: any; + hooks = {}; + + constructor(data: any) { + this.data = data; + } + + on(name: string, func: (...args: any[]) => Promise) { + this.hooks[name] = func; + } + + async emitAsync(name: string, ...args: any[]) { + const func = this.hooks[name]; + if (func) { + await func(...args); + } + } + + getRepository() { + return { + find: async () => { + return this.data; + }, + }; + } +} + +describe('storer', () => { + let db: any; + let storer: Storer; + let cache: Cache; + const data = [ + { + name: 'test1', + enabled: true, + }, + { + name: 'test2', + enabled: true, + }, + ]; + + beforeEach(async () => { + const cacheManager = new CacheManager(); + cache = await cacheManager.createCache({ name: 'test' }); + db = new MockDB(data); + storer = new Storer({ db, cache }); + }); + + afterEach(() => { + db = undefined; + storer = undefined; + }); + + it('should get authenticator from cache', async () => { + expect(await cache.get('authenticators')).toBeUndefined(); + let authenticator = await storer.get('test1'); + expect(authenticator).toBeDefined(); + + expect(await cache.get('authenticators')).toBeDefined(); + authenticator = await storer.get('test1'); + expect(authenticator).toBeDefined(); + }); + + it('should delete from cache on afterDestory', async () => { + expect(await storer.get('test1')).toBeDefined(); + await db.emitAsync('authenticators.afterDestroy', data[0]); + const authenticators = await cache.get('authenticators'); + expect(authenticators['test1']).toBeUndefined(); + }); + + it('should delete from cache on afterSave as disabled', async () => { + expect(await storer.get('test1')).toBeDefined(); + await db.emitAsync('authenticators.afterSave', { ...data[0], enabled: false }); + const authenticators = await cache.get('authenticators'); + expect(authenticators['test1']).toBeUndefined(); + }); + + it('should set cache on afterSave as enabled', async () => { + expect(await storer.get('test1')).toBeDefined(); + await db.emitAsync('authenticators.afterSave', { name: 'test3', enabled: true }); + const authenticators = await cache.get('authenticators'); + expect(authenticators['test3']).toBeDefined(); + }); +}); diff --git a/src/server/__tests__/token-blacklist.test.ts b/src/server/__tests__/token-blacklist.test.ts new file mode 100644 index 0000000..aa476d9 --- /dev/null +++ b/src/server/__tests__/token-blacklist.test.ts @@ -0,0 +1,80 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ITokenBlacklistService } from '@nocobase/auth'; +import Database, { Repository } from '@nocobase/database'; +import { MockServer, createMockServer } from '@nocobase/test'; + +describe('token-blacklist', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + let tokenBlacklist: ITokenBlacklistService; + + beforeAll(async () => { + app = await createMockServer({ + plugins: ['auth'], + }); + db = app.db; + repo = db.getRepository('tokenBlacklist'); + tokenBlacklist = app.authManager.jwt.blacklist; + }); + + afterAll(async () => { + await app.destroy(); + }); + + afterEach(async () => { + await repo.destroy({ + truncate: true, + }); + }); + + it('add and has correctly', async () => { + await tokenBlacklist.add({ + token: 'test', + expiration: new Date(), + }); + + await tokenBlacklist.add({ + token: 'test1', + expiration: new Date(), + }); + + expect(tokenBlacklist.has('test')).toBeTruthy(); + expect(tokenBlacklist.has('test1')).toBeTruthy(); + }); + + it('add same token correctly', async () => { + await tokenBlacklist.add({ + token: 'test', + expiration: new Date(), + }); + + await tokenBlacklist.add({ + token: 'test', + expiration: new Date(), + }); + + expect(tokenBlacklist.has('test')).toBeTruthy(); + }); + + it('delete expired token correctly', async () => { + await tokenBlacklist.add({ + token: 'should be deleted', + expiration: new Date('2020-01-01'), + }); + await tokenBlacklist.add({ + token: 'should not be deleted', + expiration: new Date('2100-01-01'), + }); + expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy(); + expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy(); + }); +}); diff --git a/src/server/actions/auth.ts b/src/server/actions/auth.ts new file mode 100644 index 0000000..2edb3d0 --- /dev/null +++ b/src/server/actions/auth.ts @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/* istanbul ignore file -- @preserve */ +import { Context, Next } from '@nocobase/actions'; + +export default { + LwlostPassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.LwlostPassword(); + await next(); + }, + LwresetPassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.LwresetPassword(); + await next(); + }, + LwgetUserByResetToken: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.LwgetUserByResetToken(); + await next(); + }, + LwchangePassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.LwchangePassword(); + await next(); + }, +}; diff --git a/src/server/actions/authenticators.ts b/src/server/actions/authenticators.ts new file mode 100644 index 0000000..72ad82b --- /dev/null +++ b/src/server/actions/authenticators.ts @@ -0,0 +1,100 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Context, Next } from '@nocobase/actions'; +import { Model, Repository } from '@nocobase/database'; +import { namespace } from '../../preset'; +import { AuthManager } from '@nocobase/auth'; + +async function checkCount(repository: Repository, id: number[]) { + // TODO(yangqia): This is a temporary solution, may cause concurrency problem. + const count = await repository.count({ + filter: { + enabled: true, + id: { + $ne: id, + }, + }, + }); + if (count <= 0) { + throw new Error('Please keep and enable at least one authenticator'); + } +} + +export default { + listTypes: async (ctx: Context, next: Next) => { + ctx.body = ctx.app.authManager.listTypes(); + await next(); + }, + publicList: async (ctx: Context, next: Next) => { + const repo = ctx.db.getRepository('authenticators'); + const authManager = ctx.app.authManager as AuthManager; + const authenticators = await repo.find({ + fields: ['name', 'authType', 'title', 'options', 'sort'], + filter: { + enabled: true, + }, + sort: 'sort', + }); + ctx.body = authenticators.map((authenticator: Model) => { + const authType = authManager.getAuthConfig(authenticator.authType); + return { + name: authenticator.name, + authType: authenticator.authType, + authTypeTitle: authType?.title || '', + title: authenticator.title, + options: authenticator.options?.public || {}, + }; + }); + await next(); + }, + destroy: async (ctx: Context, next: Next) => { + const repository = ctx.db.getRepository('authenticators'); + const { filterByTk, filter } = ctx.action.params; + try { + await checkCount(repository, filterByTk); + } catch (err) { + ctx.throw(400, ctx.t(err.message, { ns: namespace })); + } + const instance = await repository.destroy({ + filter, + filterByTk, + context: ctx, + }); + + ctx.body = instance; + await next(); + }, + update: async (ctx: Context, next: Next) => { + const repository = ctx.db.getRepository('authenticators'); + const { forceUpdate, filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = + ctx.action.params; + + if (!values.enabled) { + try { + await checkCount(repository, values.id); + } catch (err) { + ctx.throw(400, ctx.t(err.message, { ns: namespace })); + } + } + + ctx.body = await repository.update({ + filterByTk, + values, + whitelist, + blacklist, + filter, + updateAssociationValues, + context: ctx, + forceUpdate, + }); + + await next(); + }, +}; diff --git a/src/server/basic-auth.ts b/src/server/basic-auth.ts new file mode 100644 index 0000000..c77b76b --- /dev/null +++ b/src/server/basic-auth.ts @@ -0,0 +1,166 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { AuthConfig, BaseAuth } from '@nocobase/auth'; +import { PasswordField } from '@nocobase/database'; +import crypto from 'crypto'; +import { namespace } from '../preset'; + +export class BasicAuth extends BaseAuth { + constructor(config: AuthConfig) { + const userCollection = config.ctx.db.getCollection('users'); + super({ ...config, userCollection }); + } + + async validate() { + const ctx = this.ctx; + const { + account, // Username or email + email, // Old parameter, compatible with old api + password, + } = ctx.action.params.values || {}; + + if (!account && !email) { + ctx.throw(400, ctx.t('Please enter your username or email', { ns: namespace })); + } + const filter = email + ? { email } + : { + $or: [{ username: account }, { email: account }], + }; + const user = await this.userRepository.findOne({ + filter, + }); + + if (!user) { + ctx.throw(401, ctx.t('The username or email is incorrect, please re-enter', { ns: namespace })); + } + + const field = this.userCollection.getField('password'); + const valid = await field.verify(password, user.password); + if (!valid) { + ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace })); + } + return user; + } + + async signUp() { + const ctx = this.ctx; + const options = this.authenticator.options?.public || {}; + if (!options.allowSignUp) { + ctx.throw(403, ctx.t('Not allowed to sign up', { ns: namespace })); + } + const User = ctx.db.getRepository('users'); + const { values } = ctx.action.params; + const { username, password, confirm_password } = values; + if (!this.validateUsername(username)) { + ctx.throw(400, ctx.t('Please enter a valid username', { ns: namespace })); + } + if (!password) { + ctx.throw(400, ctx.t('Please enter a password', { ns: namespace })); + } + if (password !== confirm_password) { + ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); + } + const user = await User.create({ values: { username, password } }); + return user; + } + + /* istanbul ignore next -- @preserve */ + async lostPassword() { + const ctx = this.ctx; + const { + values: { email }, + } = ctx.action.params; + if (!email) { + ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); + } + const user = await this.userRepository.findOne({ + where: { + email, + }, + }); + if (!user) { + ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace })); + } + user.resetToken = crypto.randomBytes(20).toString('hex'); + await user.save(); + return user; + } + + /* istanbul ignore next -- @preserve */ + async resetPassword() { + const ctx = this.ctx; + const { + values: { email, password, resetToken }, + } = ctx.action.params; + const user = await this.userRepository.findOne({ + where: { + email, + resetToken, + }, + }); + if (!user) { + ctx.throw(404); + } + user.token = null; + user.resetToken = null; + user.password = password; + await user.save(); + return user; + } + + /* istanbul ignore next -- @preserve */ + async getUserByResetToken() { + const ctx = this.ctx; + const { token } = ctx.action.params; + const user = await this.userRepository.findOne({ + where: { + resetToken: token, + }, + }); + if (!user) { + ctx.throw(401); + } + return user; + } + + async changePassword() { + const ctx = this.ctx; + const { + values: { oldPassword, newPassword, confirmPassword }, + } = ctx.action.params; + if (newPassword !== confirmPassword) { + ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); + } + const currentUser = ctx.auth.user; + if (!currentUser) { + ctx.throw(401); + } + let key: string; + if (currentUser.username) { + key = 'username'; + } else { + key = 'email'; + } + const user = await this.userRepository.findOne({ + where: { + [key]: currentUser[key], + }, + }); + const pwd = this.userCollection.getField('password'); + const isValid = await pwd.verify(oldPassword, user.password); + if (!isValid) { + ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace })); + } + user.password = newPassword; + await user.save(); + return currentUser; + } +} diff --git a/src/server/collections/.gitkeep b/src/server/collections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/server/collections/authenticators.ts b/src/server/collections/authenticators.ts new file mode 100644 index 0000000..4b3badf --- /dev/null +++ b/src/server/collections/authenticators.ts @@ -0,0 +1,108 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineCollection } from '@nocobase/database'; + +/** + * Collection for extended authentication methods, + */ +export default defineCollection({ + dumpRules: { + group: 'third-party', + }, + shared: true, + name: 'authenticators', + sortable: true, + model: 'AuthModel', + createdBy: true, + updatedBy: true, + logging: true, + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'name', + allowNull: false, + unique: true, + uiSchema: { + type: 'string', + title: '{{t("Name")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'authType', + allowNull: false, + uiSchema: { + type: 'string', + title: '{{t("Auth Type")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'title', + uiSchema: { + type: 'string', + title: '{{t("Title")}}', + 'x-component': 'Input', + }, + translation: true, + }, + { + interface: 'textarea', + type: 'string', + name: 'description', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Description")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + type: 'json', + name: 'options', + allowNull: false, + defaultValue: {}, + }, + { + type: 'boolean', + name: 'enabled', + defaultValue: false, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'users', + target: 'users', + foreignKey: 'authenticator', + otherKey: 'userId', + onDelete: 'CASCADE', + sourceKey: 'name', + targetKey: 'id', + through: 'usersAuthenticators', + }, + ], +}); diff --git a/src/server/collections/sharedForms.ts b/src/server/collections/sharedForms.ts deleted file mode 100644 index 5d0357c..0000000 --- a/src/server/collections/sharedForms.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { defineCollection } from '@nocobase/database'; - -export default defineCollection({ - name: 'sharedForms', - filterTargetKey: 'slug', - fields: [ - { - type: 'uid', - name: 'slug', - }, - { - type: 'string', - name: 'title', - }, - { - type: 'string', - name: 'dataSource', - }, - { - type: 'string', - name: 'collection', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'password', - name: 'password', - hidden: true, - }, - ], -}); diff --git a/src/server/collections/token-blacklist.ts b/src/server/collections/token-blacklist.ts new file mode 100644 index 0000000..1115054 --- /dev/null +++ b/src/server/collections/token-blacklist.ts @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineCollection } from '@nocobase/database'; + +export default defineCollection({ + dumpRules: { + group: 'log', + }, + shared: true, + name: 'tokenBlacklist', + model: 'TokenBlacklistModel', + fields: [ + { + type: 'string', + name: 'token', + index: true, + }, + { + type: 'date', + name: 'expiration', + }, + ], +}); diff --git a/src/server/collections/users-authenticators.ts b/src/server/collections/users-authenticators.ts new file mode 100644 index 0000000..5130581 --- /dev/null +++ b/src/server/collections/users-authenticators.ts @@ -0,0 +1,77 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineCollection } from '@nocobase/database'; + +/** + * Collection for user information of extended authentication methods, + * such as saml, oicd, oauth, sms, etc. + */ +export default defineCollection({ + dumpRules: { + group: 'user', + }, + shared: true, + name: 'usersAuthenticators', + model: 'UserAuthModel', + createdBy: true, + updatedBy: true, + logging: true, + fields: [ + /** + * uuid: + * Unique user id of the authentication method, such as wechat openid, phone number, etc. + */ + { + name: 'uuid', + interface: 'input', + type: 'string', + allowNull: false, + uiSchema: { + type: 'string', + title: '{{t("UUID")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'nickname', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Nickname")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'attachment', + type: 'string', + name: 'avatar', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Avatar")}}', + 'x-component': 'Upload', + }, + }, + /** + * meta: + * Metadata, some other information of the authentication method. + */ + { + type: 'json', + name: 'meta', + defaultValue: {}, + }, + ], +}); diff --git a/src/server/index.ts b/src/server/index.ts index b68aea5..564510e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1 +1,13 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { BasicAuth } from './basic-auth'; +export { AuthModel } from './model/authenticator'; + export { default } from './plugin'; diff --git a/src/server/locale/en-US.ts b/src/server/locale/en-US.ts new file mode 100644 index 0000000..a5780ac --- /dev/null +++ b/src/server/locale/en-US.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + 'The email is incorrect, please re-enter': 'The email is incorrect, please re-enter', + 'Please fill in your email address': 'Please fill in your email address', + 'The password is incorrect, please re-enter': 'The password is incorrect, please re-enter', + 'Not a valid cellphone number, please re-enter': 'Not a valid cellphone number, please re-enter', + 'The phone number has been registered, please login directly': + 'The phone number has been registered, please login directly', + 'The phone number is not registered, please register first': + 'The phone number is not registered, please register first', +}; diff --git a/src/server/locale/fr-FR.ts b/src/server/locale/fr-FR.ts new file mode 100644 index 0000000..58967f0 --- /dev/null +++ b/src/server/locale/fr-FR.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + 'The email is incorrect, please re-enter': 'L\'email est incorrect, veuillez le saisir à nouveau', + 'Please fill in your email address': 'Veuillez remplir votre adresse e-mail', + 'The password is incorrect, please re-enter': 'Le mot de passe est incorrect, veuillez le saisir à nouveau', + 'Not a valid cellphone number, please re-enter': 'Numéro de téléphone portable non valide, veuillez le saisir à nouveau', + 'The phone number has been registered, please login directly': + 'Le numéro de téléphone a été enregistré, veuillez vous connecter directement', + 'The phone number is not registered, please register first': + 'Le numéro de téléphone n\'est pas enregistré, veuillez vous inscrire d\'abord', +}; diff --git a/src/server/locale/index.ts b/src/server/locale/index.ts new file mode 100644 index 0000000..29e1be6 --- /dev/null +++ b/src/server/locale/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; +export { default as ptBR } from './pt-BR'; diff --git a/src/server/locale/ja-JP.ts b/src/server/locale/ja-JP.ts new file mode 100644 index 0000000..63406d5 --- /dev/null +++ b/src/server/locale/ja-JP.ts @@ -0,0 +1,13 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + 'Please fill in your email address': 'メールアドレスを入力してください', + 'The password is incorrect, please re-enter': 'パスワードが正しくありません。再度入力してください。', +}; diff --git a/src/server/locale/pt-BR.ts b/src/server/locale/pt-BR.ts new file mode 100644 index 0000000..6da802d --- /dev/null +++ b/src/server/locale/pt-BR.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + 'The email is incorrect, please re-enter': 'O e-mail está incorreto, por favor, digite novamente', + 'Please fill in your email address': 'Por favor, preencha o seu endereço de e-mail', + 'The password is incorrect, please re-enter': 'A senha está incorreta, por favor, digite novamente', + 'Not a valid cellphone number, please re-enter': 'Número de celular inválido, por favor, digite novamente', + 'The phone number has been registered, please login directly': + 'O número de celular já está registrado, por favor, faça login diretamente', + 'The phone number is not registered, please register first': + 'O número de celular não está registrado, por favor, registre-se primeiro', +}; diff --git a/src/server/locale/zh-CN.ts b/src/server/locale/zh-CN.ts new file mode 100644 index 0000000..de99c16 --- /dev/null +++ b/src/server/locale/zh-CN.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + 'The username or email is incorrect, please re-enter': '用户名或邮箱有误,请重新输入', + 'The password is incorrect, please re-enter': '密码有误,请重新输入', + 'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入', + 'The phone number has been registered, please login directly': '手机号已注册,请直接登录', + 'The phone number is not registered, please register first': '手机号未注册,请先注册', + 'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器', + 'Please enter your username or email': '请输入用户名或邮箱', + 'Please enter a valid username': '请输入有效的用户名', +}; diff --git a/src/server/migrations/20230506152253-basic-authenticator.ts b/src/server/migrations/20230506152253-basic-authenticator.ts new file mode 100644 index 0000000..2965da3 --- /dev/null +++ b/src/server/migrations/20230506152253-basic-authenticator.ts @@ -0,0 +1,32 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; +import { presetAuthType, presetAuthenticator } from '../../preset'; + +export default class AddBasicAuthMigration extends Migration { + appVersion = '<0.14.0-alpha.1'; + async up() { + const repo = this.context.db.getRepository('authenticators'); + const existed = await repo.count(); + if (existed) { + return; + } + await repo.create({ + values: { + name: presetAuthenticator, + authType: presetAuthType, + description: 'Sign in with username/email.', + enabled: true, + }, + }); + } + + async down() {} +} diff --git a/src/server/migrations/20230607174500-update-basic.ts b/src/server/migrations/20230607174500-update-basic.ts new file mode 100644 index 0000000..5c9932f --- /dev/null +++ b/src/server/migrations/20230607174500-update-basic.ts @@ -0,0 +1,35 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; +import { presetAuthenticator } from '../../preset'; + +export default class UpdateBasicAuthMigration extends Migration { + appVersion = '<0.14.0-alpha.1'; + async up() { + const SystemSetting = this.context.db.getRepository('systemSettings'); + const setting = await SystemSetting.findOne(); + const allowSignUp = setting.get('allowSignUp') ? true : false; + const repo = this.context.db.getRepository('authenticators'); + await repo.update({ + values: { + options: { + public: { + allowSignUp, + }, + }, + }, + filter: { + name: presetAuthenticator, + }, + }); + } + + async down() {} +} diff --git a/src/server/migrations/20231218132032-fix-allow-signup.ts b/src/server/migrations/20231218132032-fix-allow-signup.ts new file mode 100644 index 0000000..a111ed5 --- /dev/null +++ b/src/server/migrations/20231218132032-fix-allow-signup.ts @@ -0,0 +1,42 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; +import { presetAuthType } from '../../preset'; + +export default class FixAllowSignUpMigration extends Migration { + appVersion = '<0.18.0-alpha.1'; + async up() { + const repo = this.context.db.getRepository('authenticators'); + const authenticators = await repo.find({ + filter: { + authType: presetAuthType, + }, + }); + for (const authenticator of authenticators) { + const options = authenticator.get('options'); + const oldAllowSignUp = options?.public?.allowSignup; + if (oldAllowSignUp === undefined || oldAllowSignUp === null) { + continue; + } + options.public.allowSignUp = oldAllowSignUp; + delete options.public.allowSignup; + await repo.update({ + values: { + options, + }, + filter: { + name: authenticator.name, + }, + }); + } + } + + async down() {} +} diff --git a/src/server/model/authenticator.ts b/src/server/model/authenticator.ts new file mode 100644 index 0000000..1fe7b04 --- /dev/null +++ b/src/server/model/authenticator.ts @@ -0,0 +1,61 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Authenticator } from '@nocobase/auth'; +import { Database, Model } from '@nocobase/database'; + +export class AuthModel extends Model implements Authenticator { + declare authType: string; + declare options: any; + + async findUser(uuid: string) { + let user: Model; + const users = await this.getUsers({ + through: { + where: { uuid }, + }, + }); + if (users.length) { + user = users[0]; + return user; + } + } + + async newUser(uuid: string, userValues?: any) { + let user: Model; + const db: Database = (this.constructor as any).database; + await this.sequelize.transaction(async (transaction) => { + // Create a new user if not exists + user = await this.createUser( + userValues || { + nickname: uuid, + }, + { + through: { + uuid: uuid, + }, + transaction, + }, + ); + await db.emitAsync(`users.afterCreateWithAssociations`, user, { + transaction, + }); + }); + return user; + } + + async findOrCreateUser(uuid: string, userValues?: any) { + const user = await this.findUser(uuid); + if (user) { + return user; + } + + return await this.newUser(uuid, userValues); + } +} diff --git a/src/server/plugin.ts b/src/server/plugin.ts index 94fa19b..730d43b 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -1,51 +1,114 @@ -import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; -import { Plugin } from '@nocobase/server'; +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ -export class PluginSharedFormsServer extends Plugin { - async afterAdd() {} +import { Cache } from '@nocobase/cache'; +import { Model } from '@nocobase/database'; +import { InstallOptions, Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { namespace, presetAuthType, presetAuthenticator } from '../preset'; +import authActions from './actions/auth'; +import authenticatorsActions from './actions/authenticators'; +import { BasicAuth } from './basic-auth'; +import { AuthModel } from './model/authenticator'; +import { Storer } from './storer'; +import { TokenBlacklistService } from './token-blacklist'; +import { tval } from '@nocobase/utils'; - async beforeLoad() {} +export class PluginAuthServer extends Plugin { + cache: Cache; + + afterAdd() {} + async beforeLoad() { + this.app.db.registerModels({ AuthModel }); + } async load() { - this.app.dataSourceManager.afterAddDataSource((dataSource) => { - dataSource.resourceManager.registerActionHandlers({ - publicSubmit: async (ctx, next) => { - ctx.body = 'ok'; - await next(); - }, - }); - }); - this.app.resourceManager.registerActionHandlers({ - 'sharedForms:getMeta': async (ctx, next) => { - const { filterByTk } = ctx.action.params; - const sharedForms = this.db.getRepository('sharedForms'); - const uiSchema = this.db.getRepository('uiSchemas'); - const instance = await sharedForms.findOne({ - filter: { - slug: filterByTk, - }, - }); - const schema = await uiSchema.getJsonSchema(filterByTk); - ctx.body = { - collections: [], - token: this.app.authManager.jwt.sign({ - // todo - }), - schema, - }; - await next(); + // Set up database + await this.importCollections(resolve(__dirname, 'collections')); + this.db.addMigrations({ + namespace: 'auth', + directory: resolve(__dirname, 'migrations'), + context: { + plugin: this, }, }); - this.app.acl.allow('sharedForms', 'getMeta', 'public'); - } + this.cache = await this.app.cacheManager.createCache({ + name: 'auth', + prefix: 'auth', + store: 'memory', + }); - async install() {} + // Set up auth manager and register preset auth type + const storer = new Storer({ + db: this.db, + cache: this.cache, + }); + this.app.authManager.setStorer(storer); - async afterEnable() {} + if (!this.app.authManager.jwt.blacklist) { + // If blacklist service is not set, should configure default blacklist service + this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this)); + } - async afterDisable() {} + this.app.authManager.registerTypes(presetAuthType, { + auth: BasicAuth, + title: tval('Password', { ns: namespace }), + }); + // Register actions + Object.entries(authActions).forEach( + ([action, handler]) => this.app.resourcer.getResource('auth')?.addAction(action, handler), + ); + Object.entries(authenticatorsActions).forEach(([action, handler]) => + this.app.resourcer.registerAction(`authenticators:${action}`, handler), + ); + // Set up ACL + ['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action)); + ['signOut', 'changePassword'].forEach((action) => this.app.acl.allow('auth', action, 'loggedIn')); + this.app.acl.allow('authenticators', 'publicList'); + this.app.acl.registerSnippet({ + name: `pm.${this.name}.authenticators`, + actions: ['authenticators:*'], + }); + // Change cache when user changed + this.app.db.on('users.afterSave', async (user: Model) => { + const cache = this.app.cache as Cache; + await cache.set(`auth:${user.id}`, user.toJSON()); + }); + this.app.db.on('users.afterDestroy', async (user: Model) => { + const cache = this.app.cache as Cache; + await cache.del(`auth:${user.id}`); + }); + } + + async install(options?: InstallOptions) { + const repository = this.db.getRepository('authenticators'); + const exist = await repository.findOne({ filter: { name: presetAuthenticator } }); + if (exist) { + return; + } + + await repository.create({ + values: { + name: presetAuthenticator, + authType: presetAuthType, + description: 'Sign in with username/email.', + enabled: true, + options: { + public: { + allowSignUp: true, + }, + }, + }, + }); + } async remove() {} } -export default PluginSharedFormsServer; +export default PluginAuthServer; diff --git a/src/server/storer.ts b/src/server/storer.ts new file mode 100644 index 0000000..99b0f77 --- /dev/null +++ b/src/server/storer.ts @@ -0,0 +1,62 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Storer as IStorer } from '@nocobase/auth'; +import { Cache } from '@nocobase/cache'; +import { Database, Model } from '@nocobase/database'; +import { AuthModel } from './model/authenticator'; + +export class Storer implements IStorer { + db: Database; + cache: Cache; + key = 'authenticators'; + + constructor({ db, cache }: { db: Database; cache: Cache }) { + this.db = db; + this.cache = cache; + + this.db.on('authenticators.afterSave', async (model: AuthModel) => { + if (!model.enabled) { + await this.cache.delValueInObject(this.key, model.name); + return; + } + await this.cache.setValueInObject(this.key, model.name, model); + }); + this.db.on('authenticators.afterDestroy', async (model: AuthModel) => { + await this.cache.delValueInObject(this.key, model.name); + }); + } + + async getCache(): Promise { + const authenticators = (await this.cache.get(this.key)) as Record; + if (!authenticators) { + return []; + } + return Object.values(authenticators); + } + + async setCache(authenticators: AuthModel[]) { + const obj = authenticators.reduce((obj, authenticator) => { + obj[authenticator.name] = authenticator; + return obj; + }, {}); + await this.cache.set(this.key, obj); + } + + async get(name: string) { + let authenticators = await this.getCache(); + if (!authenticators.length) { + const repo = this.db.getRepository('authenticators'); + authenticators = await repo.find({ filter: { enabled: true } }); + await this.setCache(authenticators); + } + const authenticator = authenticators.find((authenticator: Model) => authenticator.name === name); + return authenticator || authenticators[0]; + } +} diff --git a/src/server/token-blacklist.ts b/src/server/token-blacklist.ts new file mode 100644 index 0000000..11dee1c --- /dev/null +++ b/src/server/token-blacklist.ts @@ -0,0 +1,84 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ITokenBlacklistService } from '@nocobase/auth'; +import { Repository } from '@nocobase/database'; +import { CronJob } from 'cron'; +import AuthPlugin from './plugin'; +import { BloomFilter } from '@nocobase/cache'; + +export class TokenBlacklistService implements ITokenBlacklistService { + repo: Repository; + cronJob: CronJob; + bloomFilter: BloomFilter; + cacheKey = 'token-black-list'; + + constructor(protected plugin: AuthPlugin) { + this.repo = plugin.db.getRepository('tokenBlacklist'); + + // Try to create a bloom filter and cache blocked tokens in it + plugin.app.on('beforeStart', async () => { + try { + this.bloomFilter = await plugin.app.cacheManager.createBloomFilter(); + // https://redis.io/docs/data-types/probabilistic/bloom-filter/#reserving-bloom-filters + // 0.1% error rate requires 14.4 bits per item + // 14.4*1000000/8/1024/1024 = 1.72MB + await this.bloomFilter.reserve(this.cacheKey, 0.001, 1000000); + const data = await this.repo.find({ fields: ['token'], raw: true }); + const tokens = data.map((item: any) => item.token); + await this.bloomFilter.mAdd(this.cacheKey, tokens); + } catch (error) { + plugin.app.logger.error('token-blacklist: create bloom filter failed', error); + this.bloomFilter = null; + } + }); + } + + get app() { + return this.plugin.app; + } + + async has(token: string) { + if (this.bloomFilter) { + const exists = await this.bloomFilter.exists(this.cacheKey, token); + if (!exists) { + return false; + } + } + return !!(await this.repo.findOne({ + filter: { + token, + }, + })); + } + + async add(values) { + await this.deleteExpiredTokens(); + const { token } = values; + if (this.bloomFilter) { + await this.bloomFilter.add(this.cacheKey, token); + } + return this.repo.model.findOrCreate({ + defaults: values, + where: { + token, + }, + }); + } + + async deleteExpiredTokens() { + return this.repo.destroy({ + filter: { + expiration: { + $dateNotAfter: new Date(), + }, + }, + }); + } +} diff --git a/src/swagger/index.ts b/src/swagger/index.ts new file mode 100644 index 0000000..fcea160 --- /dev/null +++ b/src/swagger/index.ts @@ -0,0 +1,792 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + info: { + title: 'NocoBase API - Auth plugin', + }, + paths: { + '/auth:check': { + get: { + description: 'Check if the user is logged in', + tags: ['Auth'], + parameters: [ + { + name: 'X-Authenticator', + description: '登录方式标识', + in: 'header', + schema: { + type: 'string', + default: 'basic', + }, + }, + ], + security: [], + responses: { + 200: { + description: 'successful operation', + content: { + 'application/json': { + schema: { + allOf: [ + { + $ref: '#/components/schemas/user', + }, + { + type: 'object', + properties: { + roles: { + $ref: '#/components/schemas/roles', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + '/auth:signIn': { + post: { + description: 'Sign in', + tags: ['Basic auth'], + security: [], + parameters: [ + { + name: 'X-Authenticator', + description: '登录方式标识', + in: 'header', + schema: { + type: 'string', + default: 'basic', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { + type: 'string', + description: '邮箱', + }, + password: { + type: 'string', + description: '密码', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: { + type: 'string', + }, + user: { + $ref: '#/components/schemas/user', + }, + }, + }, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + }, + }, + }, + '/auth:signUp': { + post: { + description: 'Sign up', + tags: ['Basic auth'], + security: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { + type: 'string', + description: '邮箱', + }, + password: { + type: 'string', + description: '密码', + }, + confirm_password: { + type: 'string', + description: '确认密码', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'ok', + }, + }, + }, + }, + '/auth:signOut': { + post: { + description: 'Sign out', + tags: ['Basic auth'], + security: [], + responses: { + 200: { + description: 'ok', + }, + }, + }, + }, + '/auth:LwlostPassword': { + post: { + description: 'Lost password', + tags: ['Basic auth'], + security: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { + type: 'string', + description: '邮箱', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'successful operation', + content: { + 'application/json': { + schema: { + allOf: [ + { + $ref: '#/components/schemas/user', + }, + { + type: 'object', + properties: { + resetToken: { + type: 'string', + description: '重置密码的token', + }, + }, + }, + ], + }, + }, + }, + }, + 400: { + description: 'Please fill in your email address', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + 401: { + description: 'The email is incorrect, please re-enter', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + }, + }, + }, + '/auth:LwresetPassword': { + post: { + description: 'Reset password', + tags: ['Basic auth'], + security: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { + type: 'string', + description: '邮箱', + }, + password: { + type: 'string', + description: '密码', + }, + resetToken: { + type: 'string', + description: '重置密码的token', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'successful operation', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/user', + }, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + }, + }, + }, + '/auth:LwgetUserByResetToken': { + get: { + description: 'Get user by reset token', + tags: ['Basic auth'], + security: [], + parameters: [ + { + name: 'token', + in: 'query', + description: '重置密码的token', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/user', + }, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + }, + }, + }, + '/auth:LwchangePassword': { + post: { + description: 'Change password', + tags: ['Basic auth'], + security: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + oldPassword: { + type: 'string', + description: '旧密码', + }, + newPassword: { + type: 'string', + description: '新密码', + }, + confirmPassword: { + type: 'string', + description: '确认密码', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/user', + }, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/error', + }, + }, + }, + }, + }, + }, + }, + 'authenticators:listTypes': { + get: { + description: 'List authenticator types', + tags: ['Authenticator'], + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + 'authenticators:publicList': { + get: { + description: 'List enabled authenticators', + tags: ['Authenticator'], + security: [], + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: '登录方式标识', + }, + title: { + type: 'string', + description: '登录方式标题', + }, + authType: { + type: 'string', + description: '登录方式类型', + }, + options: { + type: 'object', + description: '登录方式公开配置', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'authenticators:create': { + post: { + description: 'Create authenticator', + tags: ['Authenticator'], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: '登录方式标识', + }, + authType: { + type: 'string', + description: '登录方式类型', + }, + options: { + type: 'object', + description: '登录方式配置', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/authenticator', + }, + }, + }, + }, + }, + }, + }, + 'authenticators:list': { + get: { + description: 'List authenticators', + tags: ['Authenticator'], + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/authenticator', + }, + }, + }, + }, + }, + }, + }, + }, + 'authenticators:get': { + get: { + description: 'Get authenticator', + tags: ['Authenticator'], + parameters: [ + { + name: 'filterByTk', + in: 'query', + description: 'ID', + required: true, + schema: { + type: 'integer', + }, + }, + ], + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/authenticator', + }, + }, + }, + }, + }, + }, + }, + 'authenticators:update': { + post: { + description: 'Update authenticator', + tags: ['Authenticator'], + parameters: [ + { + name: 'filterByTk', + in: 'query', + description: 'ID', + required: true, + schema: { + type: 'integer', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/authenticator', + }, + }, + }, + }, + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/authenticator', + }, + }, + }, + }, + }, + }, + }, + 'authenticators:destroy': { + post: { + description: 'Destroy authenticator', + tags: ['Authenticator'], + parameters: [ + { + name: 'filterByTk', + in: 'query', + description: 'ID', + required: true, + schema: { + type: 'integer', + }, + }, + ], + responses: { + 200: { + description: 'ok', + }, + }, + }, + }, + }, + components: { + schemas: { + user: { + type: 'object', + description: '用户', + properties: { + id: { + type: 'integer', + description: 'ID', + }, + nickname: { + type: 'string', + description: '昵称', + }, + email: { + type: 'string', + description: '邮箱', + }, + phone: { + type: 'string', + description: '手机号', + }, + appLang: { + type: 'string', + description: '用户使用语言', + }, + systemSettings: { + type: 'object', + description: '系统设置', + properties: { + theme: { + type: 'string', + description: '用户使用主题', + }, + }, + }, + createdAt: { + type: 'string', + format: 'date-time', + description: '创建时间', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: '更新时间', + }, + createdById: { + type: 'integer', + description: '创建人', + }, + updatedById: { + type: 'integer', + description: '更新人', + }, + }, + }, + roles: { + type: 'array', + description: '角色', + items: { + type: 'object', + properties: { + title: { + type: 'string', + description: '角色名称', + }, + name: { + type: 'string', + description: '角色标识', + }, + description: { + type: 'string', + description: '角色描述', + }, + hidden: { + type: 'boolean', + description: '是否隐藏', + }, + default: { + type: 'boolean', + description: '是否默认', + }, + allowConfigure: { + type: 'boolean', + description: '是否允许配置', + }, + allowNewMenu: { + type: 'boolean', + description: '是否允许新建菜单', + }, + snippets: { + type: 'array', + items: { + type: 'string', + }, + description: '接口权限', + }, + strategy: { + type: 'array', + description: '数据表权限策略', + items: { + type: 'object', + properties: { + actions: { + type: 'array', + items: { + type: 'string', + }, + description: '操作', + }, + }, + }, + }, + createdAt: { + type: 'string', + format: 'date-time', + description: '创建时间', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: '更新时间', + }, + }, + }, + }, + authenticator: { + type: 'object', + properties: { + id: { + type: 'integer', + description: 'ID', + }, + authType: { + type: 'string', + description: '登录方式类型', + }, + name: { + type: 'string', + description: '登录方式标识', + }, + title: { + type: 'string', + description: '登录方式标题', + }, + options: { + type: 'object', + description: '登录方式配置', + }, + description: { + type: 'string', + description: '登录方式描述', + }, + enabled: { + type: 'boolean', + description: '是否启用', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: '创建时间', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: '更新时间', + }, + createdById: { + type: 'integer', + description: '创建人', + }, + updatedById: { + type: 'integer', + description: '更新人', + }, + }, + }, + }, + }, +}; + +/* +/api/auth:check +/api/auth:signIn +/api/auth:signUp +/api/auth:signOut +/api/auth:lostPassword +/api/auth:resetPassword +/api/auth:getUserByResetToken +/api/auth:changePassword +/api/authenticators:listTypes +/api/authenticators:publicList +/api/authenticators:create +/api/authenticators:list +/api/authenticators:get +/api/authenticators:update +/api/authenticators:destroy +*/