Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fa554b87 | ||
|
|
6a0efba477 | ||
|
|
9f28f46839 | ||
|
|
14f1c97c0a | ||
|
|
fc56f514b3 | ||
|
|
358d722ce9 | ||
|
|
fe38dfbcca | ||
|
|
f98b060e2b | ||
|
|
bc9e34d0ae | ||
|
|
0cc450d1c9 | ||
|
|
688efb9ed6 | ||
|
|
ca4e559cc7 | ||
|
|
e23128b4a1 | ||
|
|
0d5f8f0708 | ||
|
|
a03bf8d371 | ||
|
|
3dc14402b0 | ||
|
|
540ca244c8 | ||
|
|
563fa0513d | ||
|
|
10c0ca4cf7 | ||
|
|
a76bab9b34 | ||
|
|
7acdf5d577 | ||
|
|
b1086e05dc | ||
|
|
ed91c83dd7 | ||
|
|
a614ee2435 | ||
|
|
c74487fbb3 | ||
|
|
876cece881 | ||
|
|
e5997ca567 | ||
|
|
c134b514c1 | ||
|
|
243869401a | ||
|
|
7339a0b84b | ||
|
|
c960c07a6a | ||
|
|
3cfdb36f06 | ||
|
|
ada95d04a4 | ||
|
|
1b004e6ccf | ||
|
|
95c33e0b0f | ||
|
|
4e6bcbc078 | ||
|
|
e17fb5bbf5 |
+1789
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU 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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
|
||||
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
|
||||
Windows) and restore with `scripts/restore.sh`.
|
||||
That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a
|
||||
version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md)
|
||||
and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides
|
||||
*when* you take the next hop rather than letting you stay put forever — see
|
||||
[docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling
|
||||
the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is
|
||||
what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it
|
||||
is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`;
|
||||
the equivalent one-liners, the pinning recipe and the rollback steps are in
|
||||
[docs/self-hosting.md](docs/self-hosting.md#backups).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`.
|
||||
FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
|
||||
frontend; packaged as a single Docker image with Docker Compose.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other
|
||||
people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode
|
||||
contributors.
|
||||
|
||||
## Note
|
||||
|
||||
This project is developed with AI assistance.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
|
||||
|
||||
Revision ID: 0079_email_canonical
|
||||
Revises: 0078_password_revoked_at
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
|
||||
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
|
||||
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
|
||||
and left prod with two user rows for one mailbox.
|
||||
|
||||
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
|
||||
migration must not import application code that will keep changing — but it means the two have to
|
||||
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
|
||||
|
||||
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
|
||||
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
|
||||
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
|
||||
this migration only records the canonical form and leaves the duplicates in place for a human.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0079_email_canonical"
|
||||
down_revision: str | None = "0078_password_revoked_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
|
||||
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
|
||||
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
|
||||
# enabled it.
|
||||
_CANON_SQL = """
|
||||
CASE
|
||||
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
|
||||
-- an empty local part after stripping falls through to plain lowercase, as the helper does
|
||||
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
|
||||
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
|
||||
ELSE lower({col})
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def _backfill(table: str) -> None:
|
||||
op.execute(
|
||||
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
|
||||
f"WHERE email IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
|
||||
)
|
||||
_backfill(table)
|
||||
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
|
||||
op.drop_column(table, "email_canonical")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""users.google_email — the address Google asserts, kept beside the one the user registered with
|
||||
|
||||
Revision ID: 0080_google_email
|
||||
Revises: 0079_email_canonical
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
|
||||
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
|
||||
That leaves two true addresses for one account and only one of them stored. This column holds the
|
||||
other, so the account menu and the Settings sign-in row can show which address Google actually
|
||||
answers with.
|
||||
|
||||
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
|
||||
column existed carry NULL until their owner next signs in with Google — and for the majority the two
|
||||
addresses are identical anyway, where the UI shows nothing either way.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0080_google_email"
|
||||
down_revision: str | None = "0079_email_canonical"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "google_email")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.invited_at — when the admin actually told this person the door was open
|
||||
|
||||
Revision ID: 0081_invite_invited_at
|
||||
Revises: 0080_google_email
|
||||
Create Date: 2026-09-05
|
||||
|
||||
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
|
||||
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
|
||||
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
|
||||
|
||||
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
|
||||
Without a record the only failure mode is writing to a real person twice, which is exactly the
|
||||
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
|
||||
the decision is made.
|
||||
|
||||
NULL means "never invited from here", which is true of every existing row: no backfill is possible
|
||||
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0081_invite_invited_at"
|
||||
down_revision: str | None = "0080_google_email"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "invited_at")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.lang — the language this person asked us in
|
||||
|
||||
Revision ID: 0082_invite_lang
|
||||
Revises: 0081_invite_invited_at
|
||||
Create Date: 2026-09-05
|
||||
|
||||
The mails an access request produces — approved, denied, invited — go to an address that often has
|
||||
no account yet, so there is no `preferences.language` to read. The signal does exist, though, and
|
||||
only at the moment of the request: the language of the page the form was submitted from, or the
|
||||
locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail
|
||||
answer in the requester's own language instead of an admin's guess about a stranger.
|
||||
|
||||
Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and
|
||||
`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled
|
||||
later. So no backfill here either — NULL means "we were never told", which is the honest state for
|
||||
every historic row, and for every address an admin whitelists by hand. Those fall back to English
|
||||
unless the admin picks otherwise on the invitation send.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0082_invite_lang"
|
||||
down_revision: str | None = "0081_invite_invited_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "lang")
|
||||
@@ -27,10 +27,18 @@ class AuditAction:
|
||||
USER_SUSPEND = "user_suspend"
|
||||
USER_UNSUSPEND = "user_unsuspend"
|
||||
USER_DELETE = "user_delete"
|
||||
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
|
||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||
USER_ACTIVATE = "user_activate"
|
||||
USER_VERIFY_RESEND = "user_verify_resend"
|
||||
USER_SETPW_LINK = "user_setpw_link"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
INVITE_DELETE = "invite_delete"
|
||||
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
|
||||
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
||||
INVITE_SEND = "invite_send"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
|
||||
+135
-8
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.security import decrypt_optional
|
||||
from app.utils import canonical_email
|
||||
|
||||
PASSWORD_MIN_LEN = 10
|
||||
|
||||
@@ -63,16 +65,126 @@ def has_write_scope(user: User) -> bool:
|
||||
return WRITE_SCOPE in tok.scopes.split()
|
||||
|
||||
|
||||
def env_allowed_canonicals() -> set[str]:
|
||||
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
|
||||
|
||||
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
|
||||
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
|
||||
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
|
||||
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
|
||||
`+`. The addresses themselves stay literal wherever we MAIL them (`admin_notify_groups`); only
|
||||
the comparison is canonical.
|
||||
"""
|
||||
return env_admin_canonicals() | {canonical_email(e) for e in settings.allowed_email_set}
|
||||
|
||||
|
||||
def env_admin_canonicals() -> set[str]:
|
||||
"""The env ADMIN_EMAILS bootstrap list, canonical — for the role check only. Kept separate from
|
||||
`env_allowed_canonicals` because being listed as an admin also grants access, but being allowed
|
||||
does not make anyone an admin."""
|
||||
return {canonical_email(e) for e in settings.admin_email_set}
|
||||
|
||||
|
||||
def is_allowed(db: Session, email: str) -> bool:
|
||||
"""May this email sign in? An approved Invite is the source of truth; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).
|
||||
|
||||
Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers
|
||||
every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving
|
||||
that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's
|
||||
Google sign-in became a silent second access request because OAuth returned the undotted primary.
|
||||
|
||||
One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox,
|
||||
the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an
|
||||
approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means
|
||||
the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is
|
||||
deleting the invite row or suspending the account — both still work, since a deleted row is no
|
||||
longer here to match and suspension is enforced at sign-in.
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
email = email.lower()
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
if inv is not None:
|
||||
return inv.status == "approved"
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
canon = canonical_email(email)
|
||||
statuses = (
|
||||
db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all()
|
||||
)
|
||||
if statuses:
|
||||
return "approved" in statuses
|
||||
return canon in env_allowed_canonicals()
|
||||
|
||||
|
||||
def can_activate(db: Session, user: User) -> bool:
|
||||
"""Is this account merely waiting for an admin to finish letting it in?
|
||||
|
||||
True when it is inactive but its address is ALREADY allowed — the shape roadmap UX-25 describes:
|
||||
the admin approved the address, the person signed up afterwards, and nothing connects the two
|
||||
(the Access-requests queue only offers Approve on a still-pending invite).
|
||||
|
||||
Gated on `is_allowed`, the same function the sign-in paths consult, because activating an address
|
||||
that is NOT allowed manufactures an inconsistent account: password sign-in only checks
|
||||
`is_active` and would let them in, while their Google sign-in would be turned away and silently
|
||||
reopened as a fresh access request. Letting in someone with no approved invite is the whitelist
|
||||
box's job — that action says so out loud.
|
||||
|
||||
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
|
||||
button that appears and a number that counts must never disagree about who is stuck.
|
||||
"""
|
||||
if user.is_demo or user.is_suspended or user.is_active:
|
||||
return False
|
||||
return is_allowed(db, user.email)
|
||||
|
||||
|
||||
def count_pending_activations(db: Session) -> int:
|
||||
"""How many sign-ups are waiting on an admin click (see can_activate). Drives the admin badge.
|
||||
|
||||
Deliberately NOT "everyone who cannot sign in": an account that has simply not confirmed its
|
||||
address yet is waiting on its owner, not on the admin, and badging that would nag about something
|
||||
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
|
||||
mail may never have arrived.
|
||||
"""
|
||||
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
|
||||
# button the admin sees cannot drift apart by one of them re-deriving the rule.
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
User.is_active.is_(False),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return sum(1 for u in rows if can_activate(db, u))
|
||||
|
||||
|
||||
def find_user_by_email(db: Session, email: str) -> User | None:
|
||||
"""The account someone means when they type an address at sign-in, password-reset or
|
||||
verify-resend. None when there isn't exactly one answer.
|
||||
|
||||
Exact spelling wins. Failing that, the address is resolved to its mailbox
|
||||
(utils.canonical_email) — so a password chosen at registration still works when the person types
|
||||
another spelling of their own Gmail, and so the three flows that look an account up by typed
|
||||
address stop being dead ends. Before this, requesting a reset for `xy@gmail.com` on an account
|
||||
registered as `x.y@gmail.com` issued nothing and said nothing: the endpoint answers uniformly by
|
||||
design, so the user got silence and no way in.
|
||||
|
||||
SEVERAL canonical candidates return None rather than a guess. That case is real — prod carries two
|
||||
accounts for one mailbox — and picking one of them for a password sign-in would hand someone the
|
||||
wrong account. The callers all treat None as "no such account", which is the uniform,
|
||||
non-enumerating answer they already gave.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
email = email.strip().lower()
|
||||
exact = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exact is not None:
|
||||
return exact
|
||||
candidates = (
|
||||
db.execute(select(User).where(User.email_canonical == canonical_email(email)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
|
||||
def get_or_create_demo_user(db: Session) -> User:
|
||||
@@ -119,6 +231,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
touch any browser session — each caller handles its own."""
|
||||
email = user.email.lower()
|
||||
user_id = user.id
|
||||
# Read the language off the row while it still exists — the confirmation below is queued as a
|
||||
# background task that runs after this session (and this user) are gone.
|
||||
lang = mailtext.lang_for_user(user)
|
||||
tok = user.token
|
||||
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
||||
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
||||
@@ -130,13 +245,25 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
)
|
||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||
db.execute(delete(User).where(User.id == user_id))
|
||||
db.execute(delete(Invite).where(Invite.email == email))
|
||||
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
||||
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
||||
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
||||
#
|
||||
# Unless ANOTHER account still lives on that mailbox — prod carries that too (two user rows for
|
||||
# one Gmail mailbox). Then NO invite goes, not even the literal one: is_allowed matches
|
||||
# canonically, so every row here is what keeps the survivor allowed, and removing any of them
|
||||
# would refuse their next Google sign-in and reopen it as a fresh access request — this epic's
|
||||
# founding bug, caused by its own cleanup. `delete_invite` refuses for exactly this reason; the
|
||||
# user row is already gone by now, so this query sees only the others.
|
||||
canon = canonical_email(email)
|
||||
if db.execute(select(User.id).where(User.email_canonical == canon)).first() is None:
|
||||
db.execute(delete(Invite).where(Invite.email_canonical == canon))
|
||||
db.commit()
|
||||
if google_token:
|
||||
background.add_task(revoke_google_token, google_token)
|
||||
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
|
||||
# self-service and admin deletion paths since both funnel through here.
|
||||
background.add_task(email_mod.send_account_deleted, email, operator_contact())
|
||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||
|
||||
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
@@ -54,6 +54,10 @@ class EditAborted(Exception):
|
||||
|
||||
# --- edit spec ------------------------------------------------------------------------------
|
||||
|
||||
# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single
|
||||
# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line.
|
||||
_MAX_SEGMENTS = 100
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||
@@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
if en > st:
|
||||
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
||||
norm.sort(key=lambda x: x["start_s"])
|
||||
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
||||
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
||||
# The cap is far above any real cut-list (the UI's own splitter produces a handful) and the
|
||||
# excess is DROPPED rather than raising: the surplus segments are the attack, the first
|
||||
# _MAX_SEGMENTS are a legitimate edit.
|
||||
del norm[_MAX_SEGMENTS:]
|
||||
if norm:
|
||||
out["segments"] = norm
|
||||
# A join always chooses a codec path: accurate=filter-concat re-encode,
|
||||
|
||||
@@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
|
||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||
"""
|
||||
|
||||
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
#
|
||||
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
|
||||
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
|
||||
# does, it must carry the original code rather than re-deriving one from this text.
|
||||
#
|
||||
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
|
||||
_RULES: tuple[tuple[str, str], ...] = (
|
||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
||||
("sign in to confirm your age", "login_required"),
|
||||
("confirm your age", "login_required"),
|
||||
@@ -91,3 +110,20 @@ def classify(msg: str | None) -> str:
|
||||
if marker in low:
|
||||
return category
|
||||
return "unknown"
|
||||
|
||||
|
||||
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
||||
"""The failure text to STORE on the job, given the raw message and its category.
|
||||
|
||||
SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection
|
||||
refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with
|
||||
extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard`
|
||||
stops the fetch aiming at our own network; this stops the remaining readout.
|
||||
|
||||
Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and
|
||||
every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate
|
||||
function rather than an inline branch in the worker so it can be pinned by a test — the raw
|
||||
text leaking back is a security regression that would otherwise pass a green suite."""
|
||||
if source_kind == "url" and code == "network":
|
||||
return NETWORK_REDACTED_MESSAGE
|
||||
return msg
|
||||
|
||||
@@ -116,6 +116,7 @@ def build_ydl_opts(
|
||||
progress_hook,
|
||||
extra: dict | None = None,
|
||||
cookiefile: str | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict:
|
||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||
|
||||
@@ -125,12 +126,20 @@ def build_ydl_opts(
|
||||
|
||||
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||
merge so a source adapter can never clobber it."""
|
||||
merge so a source adapter can never clobber it.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
s = normalize(spec)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
"outtmpl": outtmpl,
|
||||
"noplaylist": True,
|
||||
# SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A
|
||||
# URL that is PURELY a playlist or channel has no such video to fall back to, so it still
|
||||
# expands into every entry — one enqueue, an unbounded number of downloads. One job means
|
||||
# one item; a user who wants the whole list enqueues it item by item, under their quota.
|
||||
"playlist_items": "1",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
@@ -154,6 +163,16 @@ def build_ydl_opts(
|
||||
if extra:
|
||||
opts.update(extra)
|
||||
|
||||
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
||||
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
||||
#
|
||||
# A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because
|
||||
# nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused`
|
||||
# → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the
|
||||
# cap. Delete the worker's refusal and this branch silently uncaps the one user already over it.
|
||||
if max_bytes and max_bytes > 0:
|
||||
opts["max_filesize"] = max_bytes
|
||||
|
||||
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||
# be able to override the authenticated session the worker resolved for this specific user.
|
||||
if cookiefile:
|
||||
|
||||
@@ -15,7 +15,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -36,6 +37,18 @@ class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
class _OwnerRefused(Exception):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
MediaAssets are shared: two users asking for the same video and format get one row (see
|
||||
`service.get_or_create_asset`), and the generic failure handler writes the message onto it —
|
||||
correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written
|
||||
to the shared row it would fail a second user's queued job for the same video with the first
|
||||
user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with
|
||||
an empty library to free up space. So this is caught separately: the job fails, the asset goes
|
||||
back to `pending` for whoever else is holding it."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
@@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict:
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||
def _extract_with_fallback(
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
||||
):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||
|
||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
@@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie
|
||||
_make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
cookiefile=cookiefile,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
@@ -415,8 +433,17 @@ def _download(
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
# SEC-12, second half: the enqueue guard ran when the job was created, which may have been
|
||||
# hours ago and against a different DNS answer. This is where the socket actually gets
|
||||
# opened, so the check is repeated here and the job fails rather than fetching.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(url)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
|
||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||
remaining_bytes: int | None = None
|
||||
with SessionLocal() as cdb:
|
||||
asset_sig = cdb.execute(
|
||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||
@@ -425,6 +452,19 @@ def _download(
|
||||
cookiefile = credentials.resolve_cookiefile(
|
||||
cdb, user_id, url, asset_sig, spec, staging
|
||||
)
|
||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
||||
# front instead.
|
||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
# passing it down would leave the ceiling unset — the one user already at their cap would be
|
||||
# the only one downloading unbounded. Fail the job here instead. Not retryable until they
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
job_id,
|
||||
@@ -434,6 +474,7 @@ def _download(
|
||||
staging,
|
||||
adapter,
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining_bytes,
|
||||
)
|
||||
)
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
@@ -525,8 +566,31 @@ def _download(
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except _OwnerRefused as exc:
|
||||
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
||||
# so another user's job for the same video is claimed and downloaded normally instead of
|
||||
# inheriting a reason that is about someone else's account.
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
service.finalize_if_running(
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
# Released AFTER the job is terminal: the moment the asset is `pending` another job can claim
|
||||
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
||||
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
code = dlerrors.classify(msg)
|
||||
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
||||
msg = dlerrors.user_error_text(source_kind, msg, code)
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
@@ -539,7 +603,7 @@ def _download(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_code=code,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
|
||||
|
||||
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||
|
||||
DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare
|
||||
the footprint as it stood when the job was created and earlier jobs may have filled the cap since.
|
||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def remaining_bytes(db: Session, user_id: int) -> int | None:
|
||||
"""Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the
|
||||
cap minus the current footprint, floored at 0.
|
||||
|
||||
Zero is a real answer, not "no limit" — it means the cap is already met and the caller must
|
||||
REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's
|
||||
behalf: it ran when the job was queued, while the user was still under the cap, and the jobs
|
||||
that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker)
|
||||
is what keeps every max_bytes decision in this module."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return None
|
||||
return max(0, lim.max_bytes - footprint(db, user_id))
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Refuse a user-supplied download URL that points back into our own network (SEC-12).
|
||||
|
||||
The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and
|
||||
hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server
|
||||
fetch anything the server can reach: other containers on the compose network, the LAN, a cloud
|
||||
metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection
|
||||
refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner.
|
||||
|
||||
What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address
|
||||
is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer
|
||||
differently between the two moments, and the worker is where the socket is actually opened.
|
||||
|
||||
What it does NOT close, stated plainly rather than implied, because both need the same fix — gating
|
||||
the connection itself with a custom socket factory inside yt-dlp, a much larger change than this:
|
||||
|
||||
* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution
|
||||
with an internal address.
|
||||
* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it,
|
||||
and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public
|
||||
open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the
|
||||
response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back
|
||||
the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network``
|
||||
class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live
|
||||
one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text
|
||||
kept). Tracked as SEC-17.
|
||||
|
||||
The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal
|
||||
hostname into the box), which is the reachable one.
|
||||
|
||||
Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no
|
||||
user-controlled destination to guard.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
||||
REASON_SCHEME = "scheme" # not http(s)
|
||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
||||
|
||||
|
||||
class BlockedUrl(Exception):
|
||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||
REASON_* codes above."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Every address family we must not open a connection to. Checked explicitly rather than
|
||||
leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6."""
|
||||
# ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
ip = mapped
|
||||
return bool(
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _resolve(host: str, port: int | None) -> list[str]:
|
||||
"""Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no
|
||||
answer — reported as its own outcome, because "we could not look that up" and "we refuse to
|
||||
fetch that" are different things to tell a user."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedUrl(REASON_UNRESOLVABLE) from exc
|
||||
return [info[4][0] for info in infos]
|
||||
|
||||
|
||||
def check_url(raw: str) -> None:
|
||||
"""Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the
|
||||
server is allowed to fetch from. A host with even ONE forbidden address is refused: a
|
||||
round-robin answer must not become a coin flip."""
|
||||
# `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed
|
||||
# bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both
|
||||
# escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of
|
||||
# what this module exists to do: turn a bad URL into a structured, localizable refusal.
|
||||
try:
|
||||
u = urlparse((raw or "").strip())
|
||||
port = u.port
|
||||
except ValueError as exc:
|
||||
raise BlockedUrl(REASON_MALFORMED) from exc
|
||||
if u.scheme not in ("http", "https"):
|
||||
raise BlockedUrl(REASON_SCHEME)
|
||||
host = u.hostname
|
||||
if not host:
|
||||
raise BlockedUrl(REASON_NO_HOST)
|
||||
for addr in _resolve(host, port):
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError: # getaddrinfo returned something unparseable; treat as unusable
|
||||
raise BlockedUrl(REASON_PRIVATE) from None
|
||||
if _is_forbidden(ip):
|
||||
raise BlockedUrl(REASON_PRIVATE)
|
||||
+124
-100
@@ -21,7 +21,7 @@ from email.utils import formatdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app import mailtext, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
@@ -184,125 +184,149 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
|
||||
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
||||
|
||||
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
||||
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
||||
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
||||
|
||||
The link branch is the point of the C option: in the common ordering — approve first, the person
|
||||
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
||||
messages. One mail, one click.
|
||||
"""
|
||||
if verify_link:
|
||||
return mailtext.text(
|
||||
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
if has_account:
|
||||
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
|
||||
return mailtext.text("next_step.register", lang, app=settings.app_base)
|
||||
|
||||
|
||||
def _reply_contact(lang: str) -> tuple[str | None, str]:
|
||||
"""The admin address to answer to, and the closing line that names it — empty when no admin
|
||||
contact is configured, so the mail never promises a reply nobody will read."""
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
||||
f"{settings.app_base}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
|
||||
|
||||
|
||||
def send_access_approved(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
||||
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_approved.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_access_denied(email: str) -> bool:
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
|
||||
"this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
def send_access_invitation(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
||||
|
||||
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
||||
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
||||
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
||||
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
||||
|
||||
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
||||
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
|
||||
"""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_invitation.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "About your Siftlode access request", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
|
||||
line = (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
if role == "admin"
|
||||
else "Your admin access on Siftlode has been removed — you're now a regular user."
|
||||
def send_access_denied(email: str, lang: str) -> bool:
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text("access_denied.body", lang, contact=contact)
|
||||
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
|
||||
line = mailtext.text(
|
||||
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
|
||||
)
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
|
||||
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
|
||||
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_unsuspended(to: str, operator: str | None) -> bool:
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
||||
"in again." + contact + "\n\n— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
||||
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_deleted(to: str, operator: str | None) -> bool:
|
||||
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_deleted.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
|
||||
mailtext.text("contact.suspended", lang, operator=operator)
|
||||
if operator
|
||||
else ""
|
||||
else mailtext.text("contact.suspended_unknown", lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
|
||||
body = mailtext.text("account_suspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
|
||||
"""Tell the admins someone is waiting. One call per language group (see
|
||||
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
|
||||
single blast to all of them would have to pick one and be wrong for the rest.
|
||||
|
||||
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
"""
|
||||
body = mailtext.text(
|
||||
"admin_new_request.body",
|
||||
lang,
|
||||
requester=requester,
|
||||
link=f"{settings.app_base}/?admin=access-requests",
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
||||
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
|
||||
return _send(admins, subject, body, reply_to=requester)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
f"at {operator}.\n\n"
|
||||
if operator
|
||||
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
|
||||
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
|
||||
# The body says what the link's rules are. The reset mail always has; this one left the reader to
|
||||
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
||||
body = mailtext.text(
|
||||
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
||||
return _send([to], mailtext.text("verify_email.subject", lang), body)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
approve_url = f"{settings.app_base}/?admin=access-requests"
|
||||
body = (
|
||||
f"{requester} just requested access to Siftlode.\n\n"
|
||||
f"Approve or deny it here:\n{approve_url}\n"
|
||||
f"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
f"Requester's email: {requester}\n"
|
||||
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
)
|
||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||
def send_password_reset(to: str, lang: str, link: str) -> bool:
|
||||
body = mailtext.text("password_reset.body", lang, link=link)
|
||||
return _send([to], mailtext.text("password_reset.subject", lang), body)
|
||||
|
||||
|
||||
def send_verify_email(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Confirm your Siftlode email", body)
|
||||
|
||||
|
||||
def send_password_reset(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Reset your Siftlode password", body)
|
||||
|
||||
|
||||
def send_test(to: str) -> bool:
|
||||
body = (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Siftlode SMTP test", body)
|
||||
def send_test(to: str, lang: str) -> bool:
|
||||
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
||||
recipient gets (E9.5 S7).
|
||||
|
||||
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
|
||||
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
|
||||
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_language.py`
|
||||
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
|
||||
mail fails the gate instead of reaching a person.
|
||||
|
||||
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
|
||||
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
|
||||
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
SUPPORTED: tuple[str, ...] = ("en", "hu")
|
||||
DEFAULT = "en"
|
||||
|
||||
|
||||
def normalize(lang: str | None) -> str:
|
||||
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
|
||||
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
|
||||
English rather than raise inside a background task nobody is watching."""
|
||||
return lang if lang in SUPPORTED else DEFAULT
|
||||
|
||||
|
||||
def text(key: str, lang: str | None, **values: object) -> str:
|
||||
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
|
||||
unknown KEY raises, because that is a programming error the tests must surface, not something to
|
||||
paper over with an empty line in someone's inbox."""
|
||||
return MAIL_TEXT[key][normalize(lang)].format(**values)
|
||||
|
||||
|
||||
def link_expiry(hours: int, lang: str) -> str:
|
||||
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
|
||||
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
|
||||
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
|
||||
if hours >= 48 and hours % 24 == 0:
|
||||
return text("duration.days", lang, n=hours // 24)
|
||||
return text("duration.hours", lang, n=hours)
|
||||
|
||||
|
||||
def lang_for_user(user: User | None) -> str:
|
||||
"""The language a signed-up recipient gets: the one they picked in the app."""
|
||||
prefs = (user.preferences or {}) if user is not None else {}
|
||||
return normalize(prefs.get("language"))
|
||||
|
||||
|
||||
def lang_for_email(db: Session, email: str) -> str:
|
||||
"""The language for an address that may not have an account yet: their own account preference,
|
||||
else the language they filled our access-request form in, else English.
|
||||
|
||||
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
|
||||
carry no usable code instead of stopping at the first one — prod really does hold two accounts
|
||||
for a single mailbox, and only one of them may have ever picked a language.
|
||||
"""
|
||||
canon = canonical_email(email)
|
||||
for prefs in (
|
||||
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
code = (prefs or {}).get("language")
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
for code in (
|
||||
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
return DEFAULT
|
||||
|
||||
|
||||
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
|
||||
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
|
||||
MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
||||
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
|
||||
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
|
||||
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
||||
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
||||
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
|
||||
"contact.reply": {
|
||||
"en": "\n\nQuestions? Just reply to this email ({admin}).",
|
||||
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
|
||||
},
|
||||
"contact.operator": {
|
||||
"en": "\n\nQuestions? Contact the operator at {operator}.",
|
||||
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
|
||||
},
|
||||
"contact.deleted": {
|
||||
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
|
||||
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
|
||||
},
|
||||
"contact.suspended": {
|
||||
"en": (
|
||||
"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
"at {operator}.\n\n"
|
||||
),
|
||||
"hu": (
|
||||
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
|
||||
"{operator}.\n\n"
|
||||
),
|
||||
},
|
||||
"contact.suspended_unknown": {
|
||||
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
|
||||
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
|
||||
},
|
||||
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
|
||||
"next_step.verify": {
|
||||
"en": (
|
||||
"You already started an account, so there is just one step left. Confirm your email "
|
||||
"address and you're in:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page."
|
||||
),
|
||||
"hu": (
|
||||
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
|
||||
"e-mail-címed, és bent is vagy:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat."
|
||||
),
|
||||
},
|
||||
"next_step.sign_in": {
|
||||
"en": "Your account is ready — just sign in:\n\n{app}",
|
||||
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
|
||||
},
|
||||
"next_step.register": {
|
||||
"en": (
|
||||
"Create your account whenever you feel like it:\n\n"
|
||||
"{app}\n\n"
|
||||
"We'll send you a confirmation link to finish, and then you're in."
|
||||
),
|
||||
"hu": (
|
||||
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
|
||||
"{app}\n\n"
|
||||
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
|
||||
),
|
||||
},
|
||||
# ── Access decision ─────────────────────────────────────────────────────────────────────────
|
||||
"access_approved.subject": {
|
||||
"en": "You're in — your Siftlode access is approved",
|
||||
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
|
||||
},
|
||||
"access_approved.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_invitation.subject": {
|
||||
"en": "You're invited to Siftlode",
|
||||
"hu": "Meghívtak a Siftlode-ra",
|
||||
},
|
||||
"access_invitation.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||
"livestreams unless you want them.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"No rush — the door stays open.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
||||
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
||||
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_denied.subject": {
|
||||
"en": "About your Siftlode access request",
|
||||
"hu": "A Siftlode-hozzáférési kérelmedről",
|
||||
},
|
||||
"access_denied.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't "
|
||||
"approved this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
|
||||
"jóvá.\n\n"
|
||||
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
|
||||
"role_changed.subject": {
|
||||
"en": "Your Siftlode role has changed",
|
||||
"hu": "Megváltozott a szereped a Siftlode-on",
|
||||
},
|
||||
"role_changed.granted": {
|
||||
"en": (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
),
|
||||
"hu": (
|
||||
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
|
||||
"és a felhasználókat."
|
||||
),
|
||||
},
|
||||
"role_changed.revoked": {
|
||||
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
|
||||
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
|
||||
},
|
||||
"role_changed.body": {
|
||||
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
|
||||
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
|
||||
},
|
||||
"account_unsuspended.subject": {
|
||||
"en": "Your Siftlode account has been reinstated",
|
||||
"hu": "Visszaállítottuk a Siftlode-fiókod",
|
||||
},
|
||||
"account_unsuspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
|
||||
"sign in again.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
|
||||
"lépni.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_deleted.subject": {
|
||||
"en": "Your Siftlode account has been deleted",
|
||||
"hu": "Töröltük a Siftlode-fiókod",
|
||||
},
|
||||
"account_deleted.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
|
||||
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
|
||||
"meg.\n\n"
|
||||
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_suspended.subject": {
|
||||
"en": "Your Siftlode account is suspended",
|
||||
"hu": "A Siftlode-fiókod fel van függesztve",
|
||||
},
|
||||
"account_suspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n{contact}— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
|
||||
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
|
||||
),
|
||||
},
|
||||
# ── To the admins ───────────────────────────────────────────────────────────────────────────
|
||||
"admin_new_request.subject": {
|
||||
"en": "Siftlode access request from {requester}",
|
||||
"hu": "Siftlode hozzáférési kérelem: {requester}",
|
||||
},
|
||||
"admin_new_request.body": {
|
||||
"en": (
|
||||
"{requester} just requested access to Siftlode.\n\n"
|
||||
"Approve or deny it here:\n{link}\n"
|
||||
"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
"Requester's email: {requester}\n"
|
||||
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
),
|
||||
"hu": (
|
||||
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
|
||||
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
|
||||
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
|
||||
"A kérelmező e-mail-címe: {requester}\n"
|
||||
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
|
||||
),
|
||||
},
|
||||
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
|
||||
"verify_email.subject": {
|
||||
"en": "Confirm your Siftlode email",
|
||||
"hu": "Erősítsd meg a Siftlode e-mail-címed",
|
||||
},
|
||||
"verify_email.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page.\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat.\n\n"
|
||||
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"password_reset.subject": {
|
||||
"en": "Reset your Siftlode password",
|
||||
"hu": "Állíts be új Siftlode-jelszót",
|
||||
},
|
||||
"password_reset.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is "
|
||||
"unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
|
||||
"{link}\n\n"
|
||||
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"test.subject": {
|
||||
"en": "Siftlode SMTP test",
|
||||
"hu": "Siftlode SMTP-teszt",
|
||||
},
|
||||
"test.body": {
|
||||
"en": (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
|
||||
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
}
|
||||
+46
-4
@@ -17,9 +17,10 @@ from sqlalchemy import (
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
from app.utils import canonical_email
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
@@ -29,6 +30,21 @@ class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CanonicalEmailMixin:
|
||||
"""Keeps `email_canonical` derived from `email` on every write.
|
||||
|
||||
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
|
||||
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
|
||||
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
|
||||
approved user's Google sign-in silently became a second access request.
|
||||
"""
|
||||
|
||||
@validates("email")
|
||||
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
|
||||
self.email_canonical = canonical_email(value)
|
||||
return value
|
||||
|
||||
|
||||
class UpdatedAtMixin:
|
||||
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
||||
|
||||
@@ -37,7 +53,7 @@ class UpdatedAtMixin:
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -45,9 +61,21 @@ class User(Base, TimestampMixin):
|
||||
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
|
||||
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
|
||||
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
|
||||
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
|
||||
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
|
||||
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
|
||||
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
|
||||
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
|
||||
# sign-in for accounts linked before this column existed.
|
||||
google_email: Mapped[str | None] = mapped_column(String(320))
|
||||
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# When SEC-10 dropped a preset password (see auth.revoke_preset_password_if_unverified). Without
|
||||
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
|
||||
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
|
||||
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
|
||||
# password-reset eligibility check and could never be recovered — it could neither sign in nor
|
||||
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
|
||||
@@ -120,7 +148,7 @@ class AuthToken(Base, TimestampMixin):
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
class Invite(Base, CanonicalEmailMixin):
|
||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||
|
||||
@@ -128,6 +156,9 @@ class Invite(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
|
||||
# of the same mailbox, dating back to 2026-06-16.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# pending | approved | denied
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -136,6 +167,17 @@ class Invite(Base):
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
|
||||
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
|
||||
# act, and this is what stops it happening twice to a real person.
|
||||
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
|
||||
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
|
||||
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
|
||||
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason
|
||||
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
|
||||
# cannot be backfilled afterwards.
|
||||
lang: Mapped[str | None] = mapped_column(String(8))
|
||||
|
||||
|
||||
class DemoWhitelist(Base, TimestampMixin):
|
||||
|
||||
+403
-31
@@ -6,20 +6,24 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app.audit import AuditAction
|
||||
from app.auth import (
|
||||
admin_user,
|
||||
can_activate,
|
||||
count_admins,
|
||||
env_allowed_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
is_allowed,
|
||||
operator_contact,
|
||||
purge_user,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.demo import reset_demo_state
|
||||
from app.models import (
|
||||
@@ -27,29 +31,44 @@ from app.models import (
|
||||
Invite,
|
||||
User,
|
||||
)
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
from app.utils import iso, valid_email
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes.auth import (
|
||||
APPROVAL_VERIFY_TTL,
|
||||
issue_set_password_link,
|
||||
issue_verification_link,
|
||||
send_verification_link,
|
||||
)
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
|
||||
"""Which of these addresses already have an account. Batched, so listing the invites costs one
|
||||
extra query. An invite whose address has an account must not be deletable: `is_allowed` reads
|
||||
this row, so removing it would silently revoke a live user's access."""
|
||||
wanted = {e.lower() for e in emails if e}
|
||||
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
|
||||
listing the invites costs one extra query. An invite whose address has an account must not be
|
||||
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
|
||||
|
||||
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
|
||||
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
|
||||
delete button, and take away a live user's access — precisely the accident this guard exists to
|
||||
prevent."""
|
||||
wanted = {canonical_email(e) for e in emails if e}
|
||||
if not wanted:
|
||||
return set()
|
||||
rows = db.execute(select(User.email).where(User.email.in_(wanted))).scalars()
|
||||
return {e.lower() for e in rows if e}
|
||||
rows = db.execute(
|
||||
select(User.email_canonical).where(User.email_canonical.in_(wanted))
|
||||
).scalars()
|
||||
return {e for e in rows if e}
|
||||
|
||||
|
||||
def _env_allowed(email: str) -> bool:
|
||||
"""Whether the env bootstrap lists still let this address in. `is_allowed` consults them only
|
||||
when no Invite row exists, so this is exactly what deleting the row would fall back to — and the
|
||||
admin has to know that BEFORE they confirm, not from a toast afterwards."""
|
||||
email = (email or "").lower()
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
admin has to know that BEFORE they confirm, not from a toast afterwards.
|
||||
|
||||
Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is
|
||||
worse than no warning."""
|
||||
return canonical_email(email or "") in env_allowed_canonicals()
|
||||
|
||||
|
||||
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
||||
@@ -61,11 +80,19 @@ def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
||||
"requested_at": iso(inv.requested_at),
|
||||
"decided_at": iso(inv.decided_at),
|
||||
"decided_by": inv.decided_by,
|
||||
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
|
||||
# without this the admin has no way to answer "did I already write to them?" — and the only
|
||||
# failure mode is writing to a real person twice.
|
||||
"invited_at": iso(inv.invited_at),
|
||||
# Drives the delete affordance: an entry backed by an account is not removable here.
|
||||
"has_account": has_account,
|
||||
# …and whether removing it would actually take access away, or leave the env allow-list
|
||||
# granting it anyway (for an ADMIN_EMAILS address, as an admin).
|
||||
"still_allowed_by_env": _env_allowed(inv.email),
|
||||
# The language this person asked us in, or null when nobody ever told us (a hand-added
|
||||
# whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI
|
||||
# language — a guess, but a visible one the admin can correct before sending.
|
||||
"lang": inv.lang,
|
||||
}
|
||||
|
||||
|
||||
@@ -83,26 +110,70 @@ def list_invites(
|
||||
order = {"pending": 0, "approved": 1, "denied": 2}
|
||||
rows.sort(key=lambda i: order.get(i.status, 9))
|
||||
accounts = _accounts_for(db, (i.email for i in rows))
|
||||
return [_serialize(i, has_account=(i.email or "").lower() in accounts) for i in rows]
|
||||
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
|
||||
|
||||
|
||||
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
|
||||
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
|
||||
there is an unconfirmed account behind it), whether an account exists at all, and how long the
|
||||
link lasts.
|
||||
|
||||
This is the C option's whole mechanic. In the common ordering — the address is approved and the
|
||||
person signs up either side of that — they would otherwise have to find and open TWO messages:
|
||||
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
|
||||
already going out makes it one mail and one click.
|
||||
|
||||
Resolved through `find_user_by_email`, so an account registered under another spelling of the
|
||||
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
|
||||
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
|
||||
be worse than one that simply pointed at the sign-up page.
|
||||
"""
|
||||
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return None, False, hours
|
||||
if user.email_verified or not email_mod.email_enabled(db):
|
||||
return None, True, hours
|
||||
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
|
||||
|
||||
|
||||
def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
"""Approving access activates a matching (e.g. pending password-registration) account so
|
||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
|
||||
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
|
||||
|
||||
E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the
|
||||
password a legitimate registrant had just chosen and made "your password was cleared for
|
||||
security" the DEFAULT experience, because approve-before-verify is the normal ordering.
|
||||
|
||||
S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at
|
||||
`is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic
|
||||
showed that argument only holds while the address stays unverified — and S6's approval mail
|
||||
hands the real owner a confirmation link precisely so they will end that state. Nothing replaced
|
||||
the revoke, and that is deliberate: round 2 of the same review tried "the newest registration
|
||||
owns an unverified account" and reverted it, because the rule is symmetric — an attacker can
|
||||
always register last — and it handed anonymous callers a write onto a real account's credential.
|
||||
The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps
|
||||
the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path
|
||||
(auth.revoke_preset_password_if_unverified), where ownership is actually attested."""
|
||||
# Canonical, with the literal spelling preferred when both exist: approving
|
||||
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
|
||||
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
|
||||
email = email.lower()
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
||||
if user is not None and not user.is_active:
|
||||
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
|
||||
# verified must not hand a working login to a password set on it by whoever registered the
|
||||
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
|
||||
revoke_preset_password_if_unverified(user)
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
||||
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
@@ -133,7 +204,12 @@ def approve_invite(
|
||||
) -> dict:
|
||||
inv = _decide(db, invite_id, admin, approved=True)
|
||||
_activate_user_by_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email)
|
||||
# Computed here, not in the background task: issuing the token needs this request's session, and
|
||||
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
|
||||
# invitation below it is sent automatically — a reply is owed, not a nudge.
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
lang = mailtext.lang_for_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@@ -147,7 +223,9 @@ def deny_invite(
|
||||
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
|
||||
# account is activated, so this is an email only (the requester has no in-app inbox yet).
|
||||
inv = _decide(db, invite_id, admin, approved=False)
|
||||
background.add_task(email_mod.send_access_denied, inv.email)
|
||||
background.add_task(
|
||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
||||
)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@@ -165,7 +243,16 @@ def add_invite(
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
||||
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
||||
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
||||
canon = canonical_email(email)
|
||||
rows = (
|
||||
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
|
||||
if inv is None:
|
||||
inv = Invite(email=email)
|
||||
db.add(inv)
|
||||
@@ -180,6 +267,83 @@ def add_invite(
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||
|
||||
|
||||
class InviteSendIn(BaseModel):
|
||||
# Which language to write this person in. Optional so an existing caller (and a test) can post
|
||||
# an empty body, but the admin UI always sends it: this is the one recipient the app has no
|
||||
# signal about, because the admin typed the address in themselves.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/invite")
|
||||
def send_invitation(
|
||||
invite_id: int,
|
||||
body: InviteSendIn | None = None,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Tell someone their address is whitelisted — only when the admin asks for it.
|
||||
|
||||
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
|
||||
already agreed it with the person, and an address can sit approved for weeks with nobody
|
||||
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
|
||||
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
|
||||
they have already written rather than writing twice.
|
||||
|
||||
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
|
||||
undecided, and inviting a denied address would contradict the decision."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if inv.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
|
||||
)
|
||||
if not email_mod.email_enabled(db):
|
||||
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
|
||||
# but a button the admin pressed must not answer with a success toast for a mail that was
|
||||
# never sent.
|
||||
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
# An explicit pick from the dialog is recorded, so a later mail to this address matches the one
|
||||
# the admin already sent. Only the explicit pick — writing the fallback back would turn a column
|
||||
# that means "what this person told us" into "what we last guessed".
|
||||
picked = body.lang if body else None
|
||||
if picked in mailtext.SUPPORTED:
|
||||
lang = inv.lang = picked
|
||||
else:
|
||||
lang = mailtext.lang_for_email(db, inv.email)
|
||||
# Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is
|
||||
# fail-soft in the background, which is right when nobody is waiting on it — but this column
|
||||
# exists to answer "did I already write to them?", and the UI dims the button and warns about a
|
||||
# second mail on the strength of it. Stamping it before a send that then failed makes the one
|
||||
# question it answers a lie, and talks the admin out of the retry that would fix it.
|
||||
#
|
||||
# One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted
|
||||
# to render the link, which commits inside `_issue_token`. Accepted rather than chased — the
|
||||
# token is single-use, expires on its own, and was mailed nowhere; deferring it would mean
|
||||
# rewriting a helper three callers share so the handler owns its commit.
|
||||
if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours):
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "invite_send", "reason": "send_failed"}
|
||||
)
|
||||
# Only after the mail is actually out. Same as the other two paths that decide for an address
|
||||
# (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it,
|
||||
# or the mail can promise "your account is ready — just sign in" to someone `password_login` will
|
||||
# refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed
|
||||
# had quietly activated an account behind the admin's back.
|
||||
_activate_user_by_email(db, inv.email)
|
||||
inv.invited_at = datetime.now(timezone.utc)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.INVITE_SEND,
|
||||
target_type="invite",
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=has_account)
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}")
|
||||
def delete_invite(
|
||||
invite_id: int,
|
||||
@@ -200,9 +364,7 @@ def delete_invite(
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -236,7 +398,15 @@ def _serialize_user(u: User) -> dict:
|
||||
"email_verified": u.email_verified,
|
||||
"is_demo": u.is_demo,
|
||||
"has_password": u.password_hash is not None,
|
||||
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
|
||||
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
|
||||
# deciding what kind of help someone needs.
|
||||
"password_revoked_at": iso(u.password_revoked_at),
|
||||
"has_google": u.google_sub is not None,
|
||||
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
|
||||
# statement as "connected as someone else", and the second one is the one that explains a
|
||||
# confusing sign-in.
|
||||
"google_email": u.google_email if u.google_email != u.email else None,
|
||||
"created_at": iso(u.created_at),
|
||||
}
|
||||
|
||||
@@ -293,7 +463,13 @@ def set_user_role(
|
||||
target.role = role
|
||||
db.commit()
|
||||
if changed:
|
||||
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_role_changed,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
role,
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -342,7 +518,12 @@ def set_user_suspended(
|
||||
target.is_suspended = suspended
|
||||
db.commit()
|
||||
if was_suspended and not suspended:
|
||||
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_account_unsuspended,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -387,6 +568,197 @@ def admin_delete_user(
|
||||
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||
|
||||
|
||||
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) ---------------------------------
|
||||
|
||||
|
||||
def _pending_access_rows(db: Session) -> list[User]:
|
||||
"""Accounts that have signed up but still cannot sign in, newest first.
|
||||
|
||||
Two different dead ends share this list, because from the admin's chair they look the same — a
|
||||
person who registered and is still outside:
|
||||
· `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the
|
||||
Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending
|
||||
row). This is roadmap UX-25, and it is why the list exists.
|
||||
· `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to
|
||||
decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in
|
||||
Spam, so "send it again" is a real move.
|
||||
A suspended account is deliberately excluded: it cannot sign in either, but that is a decision
|
||||
already made, not a person stuck waiting on one.
|
||||
"""
|
||||
return list(
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
or_(User.is_active.is_(False), User.email_verified.is_(False)),
|
||||
)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pending-access")
|
||||
def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
"""The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot
|
||||
be derived on the client — it needs the invite/allow-list lookup — so it is answered here.
|
||||
|
||||
One `is_allowed` query per row rather than a batched re-implementation of it: the row count is
|
||||
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
|
||||
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
|
||||
keeps getting bitten by."""
|
||||
out = []
|
||||
for u in _pending_access_rows(db):
|
||||
# The whole rule in one line: this address MAY sign in, and this account still can't. An
|
||||
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
|
||||
# to the queue above, which owns that decision. Without this, every fresh registration
|
||||
# appeared in both lists at once, which UAT caught on 2026-09-05.
|
||||
if not is_allowed(db, u.email):
|
||||
continue
|
||||
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access")
|
||||
def user_access(
|
||||
user_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
|
||||
|
||||
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
||||
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
||||
per row on every load to answer a question nobody asked yet."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
return _serialize_user(target) | {
|
||||
"is_allowed": is_allowed(db, target.email),
|
||||
"can_activate": can_activate(db, target),
|
||||
"email_configured": email_mod.email_enabled(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/set-password-link")
|
||||
def create_set_password_link(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
|
||||
|
||||
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
|
||||
hands the link over by whatever channel they trust; opening it sets a password and signs out
|
||||
whoever held the old one.
|
||||
|
||||
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
|
||||
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
|
||||
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
|
||||
)
|
||||
url, expires_at = issue_set_password_link(db, target)
|
||||
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
|
||||
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
|
||||
# for whom, is the auditable fact.
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_SETPW_LINK,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
def activate_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Let in a registrant whose address was approved before they signed up (roadmap UX-25).
|
||||
|
||||
Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after
|
||||
review, and both moved authority rather than visibility: auto-activating on registration turns a
|
||||
standing whitelist entry into a bearer grant for whoever registers the address first, and
|
||||
reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval.
|
||||
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
|
||||
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
|
||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
||||
if target.is_active:
|
||||
return _serialize_user(target) # idempotent: a double click is not an error
|
||||
if not is_allowed(db, target.email):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "activate", "reason": "not_allowed", "email": target.email},
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_ACTIVATE,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
before={"is_active": False},
|
||||
after={"is_active": True},
|
||||
)
|
||||
target.is_active = True
|
||||
db.commit()
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/resend-verification")
|
||||
def resend_user_verification(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Re-send an account's confirmation link from the admin side.
|
||||
|
||||
The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an
|
||||
anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here
|
||||
the caller is already authenticated as an admin, so this one says plainly what happened.
|
||||
|
||||
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
|
||||
of the public endpoint is exactly what this exists to escape."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo or target.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
|
||||
)
|
||||
if not email_mod.email_enabled(db):
|
||||
# Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to
|
||||
# fall back on. An admin pressing a button deserves the failure, not a success toast.
|
||||
raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"})
|
||||
if not send_verification_link(db, target):
|
||||
# Same rule as the invitation above: a button an admin pressed must not answer with a
|
||||
# success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is
|
||||
# waiting on the answer; here somebody is.
|
||||
raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"})
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_VERIFY_RESEND,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "email": target.email}
|
||||
|
||||
|
||||
def _serialize_demo(row: DemoWhitelist) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
|
||||
+476
-112
@@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below."""
|
||||
import ipaddress
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
@@ -25,6 +28,8 @@ from app.auth import (
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
current_user,
|
||||
env_admin_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
google_enabled,
|
||||
is_allowed,
|
||||
@@ -37,27 +42,53 @@ from app.db import SessionLocal, get_db
|
||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import valid_email
|
||||
from app.utils import canonical_email, valid_email
|
||||
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
|
||||
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
|
||||
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
|
||||
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
|
||||
# the affordance the admin was shown.
|
||||
ADMIN_SETPW_TTL = timedelta(hours=24)
|
||||
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
|
||||
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
|
||||
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
|
||||
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
|
||||
# not silently satisfy the email gate. See password_reset_confirm.
|
||||
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
|
||||
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
||||
|
||||
|
||||
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
|
||||
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
|
||||
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
|
||||
triggered by someone who doesn't control the account."""
|
||||
if _suspend_email_limiter.allow(email):
|
||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||
triggered by someone who doesn't control the account.
|
||||
|
||||
Takes the user rather than the address because the mail needs their language, and the send runs
|
||||
as a background task — after this request's session, and the row, are out of reach."""
|
||||
if _suspend_email_limiter.allow(user.email):
|
||||
background.add_task(
|
||||
email_mod.send_account_suspended,
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
operator_contact(),
|
||||
)
|
||||
|
||||
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
@@ -90,18 +121,43 @@ def google_oauth(db: Session):
|
||||
return _oauth.google
|
||||
|
||||
|
||||
def admin_notify_emails(db: Session) -> list[str]:
|
||||
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
|
||||
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
|
||||
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
|
||||
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
|
||||
keeps a freshly-seeded instance working before any DB admin exists."""
|
||||
db_admins = db.execute(
|
||||
select(User.email).where(
|
||||
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
|
||||
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
|
||||
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
|
||||
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
|
||||
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
|
||||
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
|
||||
before any DB admin exists.
|
||||
|
||||
Grouped rather than one blast to everyone, because these recipients are people with their own UI
|
||||
language: a single message would have to pick one of them and be wrong for the rest. An env
|
||||
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
|
||||
account is (say) suspended is still written to in the language they chose."""
|
||||
rows = db.execute(
|
||||
select(User.email, User.preferences).where(
|
||||
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
||||
)
|
||||
).scalars()
|
||||
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
|
||||
).all()
|
||||
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
|
||||
for email in settings.admin_email_set:
|
||||
if email not in by_email:
|
||||
by_email[email] = mailtext.lang_for_email(db, email)
|
||||
groups: dict[str, list[str]] = {}
|
||||
for email in sorted(by_email):
|
||||
groups.setdefault(by_email[email], []).append(email)
|
||||
return groups
|
||||
|
||||
|
||||
def notify_admins_of_request(
|
||||
db: Session, background: BackgroundTasks | None, requester: str
|
||||
) -> None:
|
||||
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
|
||||
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
|
||||
for lang, recipients in admin_notify_groups(db).items():
|
||||
if background is None:
|
||||
email_mod.send_admin_new_request(recipients, lang, requester)
|
||||
else:
|
||||
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
|
||||
|
||||
|
||||
def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
@@ -147,14 +203,64 @@ def _client_ip(request: Request) -> str:
|
||||
return peer
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
class InviteRequest(NamedTuple):
|
||||
"""What an access request settled on.
|
||||
|
||||
`invite` is the row the request now lives under — which is NOT always the address that was just
|
||||
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
|
||||
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4).
|
||||
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
|
||||
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
|
||||
"""
|
||||
|
||||
invite: Invite | None
|
||||
is_new: bool
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
|
||||
"""Idempotently record an access request, and the language it was made in.
|
||||
|
||||
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
|
||||
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
|
||||
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
|
||||
not reopen a decision that has already been made for that mailbox. The row is still STORED under
|
||||
the address as typed, because that is where mail has to go.
|
||||
|
||||
`lang` is the language of the surface the request came from — the sign-in page, or the locale
|
||||
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
|
||||
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
|
||||
rather than stored, so a forged body cannot put junk in the column.
|
||||
|
||||
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
|
||||
on that — `_register_account` persists its own flushed user precisely because a no-op here
|
||||
commits nothing (E9.5 S6).
|
||||
"""
|
||||
email = (email or "").lower()
|
||||
if not valid_email(email):
|
||||
return None
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
return InviteRequest(None, False)
|
||||
lang = lang if lang in mailtext.SUPPORTED else None
|
||||
canon = canonical_email(email)
|
||||
rows = (
|
||||
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
|
||||
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
|
||||
# row exists, or the reopen branch below would fire and put one person in the admin's queue
|
||||
# twice. Only when every row is denied does the spelling decide which one reopens.
|
||||
undecided = [r for r in rows if r.status != "denied"]
|
||||
inv = (
|
||||
next((r for r in rows if r.status == "approved"), None)
|
||||
or next((r for r in undecided if r.email == email), None)
|
||||
or next(iter(undecided), None)
|
||||
or next((r for r in rows if r.email == email), None)
|
||||
or next(iter(rows), None)
|
||||
)
|
||||
if inv is not None:
|
||||
relearned = lang is not None and inv.lang != lang
|
||||
if relearned:
|
||||
inv.lang = lang
|
||||
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
||||
if inv.status == "denied":
|
||||
inv.status = "pending"
|
||||
@@ -162,12 +268,39 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
inv.decided_at = None
|
||||
inv.decided_by = None
|
||||
db.commit()
|
||||
return inv
|
||||
return None
|
||||
inv = Invite(email=email, status="pending")
|
||||
return InviteRequest(inv, True)
|
||||
if relearned:
|
||||
db.commit()
|
||||
return InviteRequest(inv, False)
|
||||
inv = Invite(email=email, status="pending", lang=lang)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
return inv
|
||||
return InviteRequest(inv, True)
|
||||
|
||||
|
||||
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
|
||||
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
|
||||
the request) or `denied` (nothing to record).
|
||||
|
||||
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
|
||||
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
|
||||
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a
|
||||
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
|
||||
"request recorded" without saying FOR WHAT hid the mismatch for a day.
|
||||
|
||||
`linked` names the address the request actually lives under, and ONLY when that differs from the
|
||||
one Google just returned — the honest version of "we didn't open a second request". Same
|
||||
spelling twice is just a repeat, and saying anything there would be noise.
|
||||
|
||||
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
|
||||
would read it back as a space and mangle exactly the address this epic exists to protect.
|
||||
"""
|
||||
params = {"access": kind}
|
||||
if email:
|
||||
params["email"] = email
|
||||
if inv is not None and inv.email != email:
|
||||
params["linked"] = inv.email
|
||||
return "/#" + urlencode(params)
|
||||
|
||||
|
||||
_DEV_OAUTH_HOSTS = frozenset(
|
||||
@@ -201,6 +334,12 @@ class EmailIn(BaseModel):
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
|
||||
# the choice exists anywhere the server can see: it lives in the browser until an account does,
|
||||
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
|
||||
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
|
||||
# the bilingual mail templates are E9.5 S7.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
@@ -283,26 +422,37 @@ async def callback(
|
||||
# A denied Google login doubles as an access request: record it for the admin and
|
||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||
if email:
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
return RedirectResponse(url="/?access=requested")
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
# Google just told us this person's locale — the only language signal that will ever
|
||||
# exist for an address with no account, and the decision mail is written from it.
|
||||
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
|
||||
user = db.query(User).filter(User.google_sub == sub).one_or_none()
|
||||
if user is None:
|
||||
# No account carries this Google identity yet. If one already exists for this email —
|
||||
# No account carries this Google identity yet. If one already exists for this MAILBOX —
|
||||
# e.g. an email+password registration — adopt the Google identity onto it instead of
|
||||
# inserting a duplicate (which would collide on the unique email and 500). Google has
|
||||
# verified the address, so this safely unifies password + Google sign-in on one account.
|
||||
existing = db.query(User).filter(User.email == email).one_or_none()
|
||||
# inserting a duplicate. Google has verified the address, so this safely unifies password +
|
||||
# Google sign-in on one account.
|
||||
#
|
||||
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
|
||||
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
|
||||
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
|
||||
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
|
||||
# matches literally is never passed over for a canonical sibling.
|
||||
canon = canonical_email(email)
|
||||
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all()
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.google_sub is not None and existing.google_sub != sub:
|
||||
# A different Google identity is already bound to this email — refuse rather than
|
||||
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
||||
log.warning("Google login email collision (different sub): %s", email)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
if not userinfo.get("email_verified", False):
|
||||
# Adopting (and activating) a pre-existing password account requires Google to
|
||||
# actually attest the address — otherwise an unverified Google email that merely
|
||||
@@ -311,7 +461,7 @@ async def callback(
|
||||
"Google login: unverified email, refusing to adopt account: %s",
|
||||
email,
|
||||
)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
user = existing
|
||||
user.google_sub = sub
|
||||
# SEC-10: drop any password pre-set on this account while its email was still unverified,
|
||||
@@ -328,22 +478,23 @@ async def callback(
|
||||
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
||||
if user.is_suspended:
|
||||
log.info("Suspended account blocked at Google login: %s", email)
|
||||
_notify_suspended(background, user.email)
|
||||
_notify_suspended(background, user)
|
||||
return RedirectResponse(url="/?login=suspended")
|
||||
# Keep the email in sync with Google, but never overwrite it with one another account already
|
||||
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
|
||||
# Google-side email change to a value that collides with a different local account).
|
||||
if user.email != email:
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
# Record what Google actually answers with, beside the address the user registered with. The two
|
||||
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
|
||||
# pattern) the UI shows both, so nobody has to guess which one signs them in.
|
||||
if email:
|
||||
user.google_email = email
|
||||
_sync_google_email(db, user, email)
|
||||
user.display_name = userinfo.get("name")
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
|
||||
# default for a brand-new account (its role attribute is still unset before flush).
|
||||
if email in settings.admin_email_set:
|
||||
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
|
||||
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
|
||||
if canonical_email(email) in env_admin_canonicals():
|
||||
user.role = "admin"
|
||||
elif not user.role:
|
||||
user.role = "user"
|
||||
@@ -355,8 +506,7 @@ async def callback(
|
||||
# picked one yet); unsupported locales fall back to English.
|
||||
prefs = dict(user.preferences or {})
|
||||
if not prefs.get("language"):
|
||||
loc = (userinfo.get("locale") or "").split("-")[0].lower()
|
||||
prefs["language"] = loc if loc in {"en", "hu"} else "en"
|
||||
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
|
||||
user.preferences = prefs
|
||||
db.flush()
|
||||
|
||||
@@ -375,6 +525,30 @@ async def callback(
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
def _sync_google_email(db: Session, user: User, email: str) -> None:
|
||||
"""Follow a genuine Google-side email change — and ONLY that.
|
||||
|
||||
A different SPELLING of the same mailbox is the user's own choice and must survive. People
|
||||
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
|
||||
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
|
||||
of the spelling they chose, because sign-in matches the stored address first. Google returns the
|
||||
account's PRIMARY address, which need not be what they registered with — and can never contain a
|
||||
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
|
||||
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is
|
||||
older.)
|
||||
|
||||
Never overwrite with an address another account already owns either: that would hit the unique
|
||||
constraint and 500, wedging this user's sign-in.
|
||||
"""
|
||||
if not email or user.email == email:
|
||||
return
|
||||
if canonical_email(user.email) == canonical_email(email):
|
||||
return # same mailbox, their spelling — leave it alone
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
|
||||
|
||||
def _store_token(db: Session, user: User, token: dict) -> None:
|
||||
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
|
||||
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
|
||||
@@ -435,6 +609,9 @@ def _complete_link(
|
||||
if user.google_sub is not None and user.google_sub != sub:
|
||||
return RedirectResponse(url="/?link=mismatch")
|
||||
user.google_sub = sub
|
||||
linked_email = (userinfo.get("email") or "").strip().lower()
|
||||
if linked_email:
|
||||
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
||||
if not user.display_name:
|
||||
user.display_name = userinfo.get("name")
|
||||
if not user.avatar_url:
|
||||
@@ -468,15 +645,23 @@ async def logout(request: Request):
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
|
||||
|
||||
class AccessRequestIn(EmailIn):
|
||||
# The language of the page this request was made from. Same reasoning as registration's `lang`
|
||||
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
|
||||
# see, since the requester has no account to carry it, and it decides what language the approval
|
||||
# or denial mail is written in.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
body: EmailIn,
|
||||
body: AccessRequestIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||
duplicate or re-spam admins (upsert returns None for an already-pending row).
|
||||
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
|
||||
|
||||
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
|
||||
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
|
||||
@@ -491,10 +676,9 @@ async def request_access(
|
||||
return {"status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
|
||||
|
||||
@@ -551,6 +735,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
|
||||
the same "does its user still exist" answer, so the two can never disagree about a given token.
|
||||
|
||||
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
|
||||
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
|
||||
authority, because a token can expire, be spent, or lose its account between the two calls.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == hash_token(raw),
|
||||
AuthToken.kind == kind,
|
||||
AuthToken.used_at.is_(None),
|
||||
AuthToken.expires_at >= datetime.now(timezone.utc),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
|
||||
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
|
||||
return db.get(User, row.user_id) if row is not None else None
|
||||
|
||||
|
||||
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
|
||||
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
|
||||
@@ -576,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
|
||||
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
|
||||
swallowed, because the caller has to treat them differently — only a mailed reset attests the
|
||||
address."""
|
||||
for kind in _PASSWORD_TOKEN_KINDS:
|
||||
user = _consume_token(db, raw, kind)
|
||||
if user is not None:
|
||||
return user, kind
|
||||
return None
|
||||
|
||||
|
||||
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
|
||||
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
|
||||
|
||||
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
|
||||
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
|
||||
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
|
||||
"no SMTP configured and someone forgot their password".
|
||||
|
||||
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
|
||||
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
|
||||
which is what evicts a squatter — see password_reset_confirm."""
|
||||
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
|
||||
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
|
||||
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
|
||||
# consume it, and a second entry shape would be a second thing to keep correct.
|
||||
return f"{_app_base()}/#reset={raw}", expires_at
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
body: EmailPasswordIn,
|
||||
@@ -606,17 +842,59 @@ def register(
|
||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||
background.add_task(_register_account, email, password)
|
||||
background.add_task(_register_account, email, password, body.lang)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _register_account(email: str, password: str) -> None:
|
||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||
"""Background worker for /register: create the pending account (+ verification email + admin
|
||||
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
||||
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
||||
with SessionLocal() as db:
|
||||
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
|
||||
return # already registered — nothing to do (the uniform "ok" was already returned)
|
||||
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
|
||||
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
|
||||
# the end that stops new ones appearing, while the Google callback copes with the existing.
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
|
||||
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
|
||||
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
|
||||
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
# Already registered. This used to return in silence, and that silence is the most
|
||||
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
|
||||
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
|
||||
# inbox", and waited for a message that did not exist.
|
||||
#
|
||||
# Re-sending the link instead leaks nothing new. The response and its timing are
|
||||
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
|
||||
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
|
||||
# for the same input — so it is not a new capability, just the end of a dead end. An
|
||||
# account that is already confirmed still gets nothing: there is nothing to confirm.
|
||||
#
|
||||
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing
|
||||
# review tried "while the address is unverified, the newest registration owns it"
|
||||
# (replace the hash, kill the outstanding verify tokens) as a fix for address
|
||||
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT.
|
||||
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
|
||||
# caller the power to overwrite a real registrant's chosen password and delete the
|
||||
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
|
||||
# NULL, password_revoked_at set) it would install a password where the whole point was
|
||||
# that there is none. The owner's real recourse already exists and needs no new
|
||||
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
|
||||
# and bumps the session epoch. The residual is logged as SEC-16.
|
||||
user = existing
|
||||
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
|
||||
send_verification_link(db, user)
|
||||
return
|
||||
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
||||
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
||||
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
||||
@@ -626,6 +904,8 @@ def _register_account(email: str, password: str) -> None:
|
||||
password_hash=hash_password(password),
|
||||
is_active=False,
|
||||
email_verified=not email_ok,
|
||||
# Only a supported code is stored, so a forged body can't put junk in preferences.
|
||||
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -646,17 +926,45 @@ def _register_account(email: str, password: str) -> None:
|
||||
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
|
||||
db.commit()
|
||||
if email_ok:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
admins = admin_notify_emails(db)
|
||||
send_verification_link(db, user)
|
||||
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
|
||||
# an address the admin already approved, nothing lands in the Access-requests queue, so this
|
||||
# is the only place the account's existence is announced at all. Delivery is a separate
|
||||
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
|
||||
# still needs the admin to notice them (roadmap UX-25).
|
||||
if admins:
|
||||
email_mod.send_admin_new_request(admins, email)
|
||||
notify_admins_of_request(db, None, email)
|
||||
|
||||
|
||||
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
||||
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
|
||||
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
|
||||
|
||||
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
|
||||
kinds of link cannot drift apart in shape or in how they are consumed."""
|
||||
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
|
||||
return f"{_app_base()}/#verify={raw}"
|
||||
|
||||
|
||||
def send_verification_link(db: Session, user: User) -> bool:
|
||||
"""Issue a fresh verification token for `user` and mail them the link.
|
||||
|
||||
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
|
||||
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
|
||||
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
|
||||
|
||||
One definition, three callers — registration, the self-service resend, and the admin-side resend
|
||||
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
|
||||
by who triggered it is a bug waiting to be discovered by exactly one user.
|
||||
|
||||
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
|
||||
admin-side resend) can say so instead of reporting success for a message that was dropped.
|
||||
"""
|
||||
return email_mod.send_verify_email(
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
issue_verification_link(db, user),
|
||||
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
@@ -664,20 +972,25 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||
on any invalid/expired/used token.
|
||||
|
||||
`password_reset_required` tells the SPA to send the user straight to the reset flow: an admin who
|
||||
approved this account BEFORE it was verified tripped SEC-10, so the password chosen at registration
|
||||
is gone and sign-in would answer with the deliberately uniform "Invalid email or password" — true,
|
||||
but unreadable to the person who just proved they own the mailbox. Saying so leaks nothing: holding
|
||||
a valid single-use verify token for this account already proves that much."""
|
||||
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
|
||||
dropped the password this account registered with, so sign-in would answer with the deliberately
|
||||
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
|
||||
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
|
||||
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no
|
||||
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this
|
||||
flag is rare now — but it must stay, because those users have no other way back in."""
|
||||
user = _consume_token(db, body.token, "verify")
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This verification link is invalid or has expired."
|
||||
)
|
||||
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
|
||||
user.email_verified = True
|
||||
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||
email = user.email
|
||||
db.commit()
|
||||
return {"ok": True, "password_reset_required": needs_password}
|
||||
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
|
||||
# password_reset_required above: holding a valid single-use verify token for this account already
|
||||
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
|
||||
# for a different account, where an unqualified "email confirmed" would be actively misleading.
|
||||
return {"ok": True, "password_reset_required": needs_password, "email": email}
|
||||
|
||||
|
||||
@router.post("/verify/resend")
|
||||
@@ -707,24 +1020,36 @@ def _resend_verify_if_eligible(email: str) -> None:
|
||||
if not email_mod.email_enabled():
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
if user is not None and not user.email_verified and not user.is_demo:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
send_verification_link(db, user)
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
|
||||
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
|
||||
user = _consume_token(db, token, "verify")
|
||||
if user is None:
|
||||
return RedirectResponse(url="/?verify=invalid")
|
||||
user.email_verified = True
|
||||
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||
db.commit()
|
||||
# Same two outcomes as the POST flow above, so an old link in flight lands on the same guidance.
|
||||
return RedirectResponse(url="/?verify=" + ("ok_reset" if needs_password else "ok"))
|
||||
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
||||
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
||||
shape so the SPA can say it in the user's own language (the old bare English strings were
|
||||
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
||||
|
||||
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
|
||||
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
|
||||
still shut. It is not an enumeration leak: this branch is only reachable once the password has
|
||||
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
|
||||
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
|
||||
real account and an unknown one.
|
||||
|
||||
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while
|
||||
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
||||
rather than assuming a sequence.
|
||||
"""
|
||||
detail: dict = {"code": "login", "reason": reason}
|
||||
if user is not None:
|
||||
detail |= {
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"approved": user.is_active,
|
||||
}
|
||||
detail |= {k: v for k, v in extra.items() if v is not None}
|
||||
return HTTPException(status_code=status, detail=detail)
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
@@ -738,29 +1063,23 @@ def password_login(
|
||||
Once the password is proven correct, the owner gets a specific reason if their account is
|
||||
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
|
||||
if not _login_limiter.allow(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
raise _login_refused(429, "rate_limited")
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
||||
# response time is the same whether or not the email exists — no timing-based enumeration.
|
||||
candidate_hash = user.password_hash if (user and not user.is_demo) else None
|
||||
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
|
||||
if user is None or user.is_demo or not password_ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
raise _login_refused(401, "invalid")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user.email)
|
||||
op = operator_contact()
|
||||
detail = "Your account has been suspended."
|
||||
if op:
|
||||
detail += f" If you have questions, contact the operator at {op}."
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
_notify_suspended(background, user)
|
||||
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Please verify your email first (check your inbox)."
|
||||
)
|
||||
raise _login_refused(403, "email_unverified", user)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
|
||||
raise _login_refused(403, "pending_approval", user)
|
||||
_establish_session(request, user)
|
||||
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||
return {"ok": True}
|
||||
@@ -797,12 +1116,35 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
|
||||
proves identity through the session rather than by mail."""
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return
|
||||
if user.password_hash or user.password_revoked_at:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
||||
# The account's own address, as above — never the spelling that was typed into the form.
|
||||
email_mod.send_password_reset(
|
||||
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-reset/check")
|
||||
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Is the reset link the SPA just opened still good? Answers without spending the token.
|
||||
|
||||
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
|
||||
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
|
||||
account and then opening its reset link — the user types a new password into a form that answers
|
||||
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
|
||||
in spam, and whoever got two mails usually clicks the older one.
|
||||
|
||||
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
|
||||
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
|
||||
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
|
||||
answer it must degrade to the old behaviour instead of declaring a live link dead.
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
@@ -813,18 +1155,25 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||
spent = _consume_password_token(db, token)
|
||||
if spent is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
||||
user, kind = spent
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
# email — this rescues an account that registered but never clicked the verification link (it
|
||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||
# here: a reset proves email ownership, not that an admin approved the account.
|
||||
user.email_verified = True
|
||||
#
|
||||
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
|
||||
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
|
||||
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
@@ -832,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id,
|
||||
AuthToken.kind == "reset",
|
||||
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
||||
AuthToken.used_at.is_(None),
|
||||
)
|
||||
).scalars():
|
||||
@@ -859,10 +1208,25 @@ def bump_session_epoch(user: User) -> None:
|
||||
def revoke_preset_password_if_unverified(user: User) -> None:
|
||||
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
||||
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
||||
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
||||
rule — both the Google-callback and admin-activation paths call it so they can't drift.
|
||||
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
||||
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
|
||||
so a legit no-SMTP registration is unaffected.
|
||||
|
||||
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and
|
||||
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate
|
||||
registrant had just chosen, and since the admin normally decides before the user reads their mail,
|
||||
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
|
||||
|
||||
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified
|
||||
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
|
||||
gate holds only while the address is unverified, and S6's approval mail exists to get the real
|
||||
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
|
||||
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
|
||||
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
|
||||
would have handed anonymous callers a write onto a real account's credential. What the owner
|
||||
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
|
||||
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
|
||||
back in front of every legitimate registrant, which is the whole reason S1 removed it.
|
||||
|
||||
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
|
||||
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
@@ -115,7 +116,7 @@ def send_test_email(
|
||||
lets the admin verify the settings actually work."""
|
||||
if not email_mod.email_enabled():
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured.")
|
||||
sent = email_mod.send_test(user.email)
|
||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||
if not sent:
|
||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
||||
return {"sent": True, "to": user.email}
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
if _VIDEO_ID.match(raw):
|
||||
return "youtube", raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
u = urlparse(raw)
|
||||
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
|
||||
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
|
||||
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
|
||||
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
|
||||
# bare English string would be exactly the split the canonical-patterns table forbids.
|
||||
try:
|
||||
u = urlparse(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
|
||||
) from exc
|
||||
host = u.netloc.lower()
|
||||
if "youtube.com" in host:
|
||||
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
||||
@@ -352,6 +364,17 @@ def enqueue_download(
|
||||
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
source_kind, source_ref = resolve_source(body.source)
|
||||
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
|
||||
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
|
||||
# move between here and the download); see downloads/urlguard.py for what that does and does
|
||||
# not cover.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(source_ref)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
||||
) from e
|
||||
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
try:
|
||||
@@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None:
|
||||
return None
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
||||
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
return url[:2048]
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
@@ -99,6 +100,7 @@ def get_me(
|
||||
if user is None:
|
||||
return {"authenticated": False}
|
||||
pending_invites = 0
|
||||
pending_activations = 0
|
||||
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
|
||||
# config costs verification links, password resets and access-request notices with nothing
|
||||
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
|
||||
@@ -108,6 +110,10 @@ def get_me(
|
||||
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
||||
or 0
|
||||
)
|
||||
# Counted separately from pending_invites rather than folded into it: the two are different
|
||||
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
|
||||
# and quietly widening one number would make an existing field mean something new.
|
||||
pending_activations = count_pending_activations(db)
|
||||
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
||||
return {
|
||||
"authenticated": True,
|
||||
@@ -118,6 +124,12 @@ def get_me(
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"has_google": user.google_sub is not None,
|
||||
# The address Google answers with, when it DIFFERS from the account's own. Same string for
|
||||
# most people, and then this is None so no surface has to decide whether to show it — the
|
||||
# comparison lives here, once, instead of in every consumer.
|
||||
"google_email": (
|
||||
user.google_email if user.google_email and user.google_email != user.email else None
|
||||
),
|
||||
"has_password": user.password_hash is not None,
|
||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
@@ -127,6 +139,9 @@ def get_me(
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
|
||||
# nothing ever reached the Access-requests queue and the badge used to read zero.
|
||||
"pending_activations": pending_activations,
|
||||
"email_configured": email_configured,
|
||||
"preferences": user.preferences or {},
|
||||
}
|
||||
|
||||
@@ -20,10 +20,30 @@ from app.sync.playlists import (
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
|
||||
# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the
|
||||
# caller owns and then every item in each, against the instance-wide YouTube budget. Its own
|
||||
# limiter rather than a shared one — spending the manual subscription-import allowance must not
|
||||
# also close the playlist button, and vice versa.
|
||||
#
|
||||
# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a
|
||||
# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of
|
||||
# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning.
|
||||
# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt,
|
||||
# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse
|
||||
# legitimate work. Their exposure is tracked as SEC-18 instead.
|
||||
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
def _check_pull_cooldown(user: User) -> None:
|
||||
"""Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again."""
|
||||
if not _playlist_pull_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
@@ -284,6 +304,7 @@ def sync_youtube(
|
||||
status_code=403,
|
||||
detail="Connect YouTube (read access) to sync your playlists.",
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
|
||||
@@ -304,6 +325,7 @@ def revert_youtube(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
|
||||
@@ -14,7 +14,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import state, sysconfig
|
||||
from app.utils import canonical_email
|
||||
from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
@@ -69,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
)
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
|
||||
user = db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(email=email, role="admin")
|
||||
db.add(user)
|
||||
@@ -103,6 +111,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
|
||||
class SetupTestEmailIn(BaseModel):
|
||||
to: str = ""
|
||||
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
|
||||
# thing that can say what language to write in; anything unsupported falls back to English.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
@@ -111,7 +122,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
|
||||
to = body.to.strip()
|
||||
if "@" not in to:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not email_mod.send_test(to):
|
||||
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
||||
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -7,12 +7,20 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
|
||||
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
|
||||
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
|
||||
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
|
||||
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
|
||||
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
@@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
if not _sync_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = m.used
|
||||
|
||||
@@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool:
|
||||
return bool(value and _EMAIL_RE.match(value))
|
||||
|
||||
|
||||
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
|
||||
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
|
||||
# Gmail only, and merely lowercases everything else.
|
||||
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
|
||||
|
||||
|
||||
def canonical_email(value: str | None) -> str:
|
||||
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
|
||||
mail always goes to the address as the user typed it.
|
||||
|
||||
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
|
||||
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
|
||||
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
|
||||
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
|
||||
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
|
||||
OAuth can only ever return the base address, meaning anyone who registers with
|
||||
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
|
||||
|
||||
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
|
||||
unless the admin turned it on, and dots are significant on most providers, so anything outside
|
||||
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
|
||||
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
|
||||
of it.
|
||||
"""
|
||||
email = (value or "").strip().lower()
|
||||
local, sep, domain = email.partition("@")
|
||||
if not sep or domain not in _GMAIL_DOMAINS:
|
||||
return email
|
||||
local = local.partition("+")[0].replace(".", "")
|
||||
# Normalise the alias domain too, so googlemail and gmail spellings meet.
|
||||
return f"{local}@gmail.com" if local else email
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Current time as a timezone-aware UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
|
||||
|
||||
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
|
||||
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
|
||||
manual sync trigger stops spending the instance-wide YouTube budget when held down.
|
||||
|
||||
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
|
||||
"""
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
|
||||
assert r.status_code == 422
|
||||
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
|
||||
# thing the canonical-patterns table forbids.
|
||||
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
|
||||
|
||||
|
||||
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# Mutation check for the test above: with a public answer the request must fail LATER (or
|
||||
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
|
||||
# first test would still pass.
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
|
||||
detail = r.json().get("detail") if r.status_code >= 400 else None
|
||||
assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
|
||||
|
||||
|
||||
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
|
||||
# user-controlled destination — resolving one would be pure latency on every enqueue.
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("the URL guard resolved DNS for a YouTube source")
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
|
||||
|
||||
|
||||
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
# Neither the YouTube grant nor the import itself is under test — the limiter is.
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
|
||||
# 3 per 10 minutes: the first three pass, the fourth is refused.
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
|
||||
|
||||
|
||||
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
|
||||
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
|
||||
# exhausting their allowance must not lock everyone else out of their own sync button.
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
a, b = make_user(), make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
ca = as_user(a)
|
||||
for _ in range(4):
|
||||
ca.post("/api/sync/subscriptions")
|
||||
assert ca.post("/api/sync/subscriptions").status_code == 429
|
||||
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
|
||||
|
||||
|
||||
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
|
||||
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
|
||||
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
|
||||
# route over. Both must draw on the one allowance.
|
||||
from app.models import Playlist
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
user = make_user()
|
||||
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
# One allowance, not one each: the bulk route is now on cooldown too.
|
||||
r = c.post("/api/playlists/sync-youtube")
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == {"code": "sync_cooldown"}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
|
||||
|
||||
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
|
||||
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
|
||||
sat locked out, and the badge read zero. The only working move was re-typing the address into the
|
||||
whitelist box, which nobody would guess.
|
||||
|
||||
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
|
||||
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
|
||||
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
|
||||
already allowed, and nothing an unauthenticated caller can reach.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _pending_access(client):
|
||||
r = client.get("/api/admin/pending-access")
|
||||
assert r.status_code == 200
|
||||
return {u["email"]: u for u in r.json()}
|
||||
|
||||
|
||||
def _stuck_registrant(db, make_user, admin):
|
||||
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
|
||||
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
return make_user(email="later@example.com", password=_PW, is_active=False)
|
||||
|
||||
|
||||
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
_stuck_registrant(db, make_user, admin)
|
||||
|
||||
row = _pending_access(as_user(admin))["later@example.com"]
|
||||
|
||||
assert row["is_active"] is False
|
||||
assert (
|
||||
row["can_activate"] is True
|
||||
) # the address is already allowed — the click just finishes it
|
||||
|
||||
|
||||
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
|
||||
# They already have a pending invite with Approve on it in the queue above, and their address is
|
||||
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
|
||||
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="normal@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
|
||||
assert "normal@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
|
||||
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
|
||||
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="fresh@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
|
||||
|
||||
assert "fresh@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
|
||||
|
||||
assert r.status_code == 200 and r.json()["is_active"] is True
|
||||
db.expire_all()
|
||||
assert db.get(User, stuck.id).is_active is True
|
||||
actions = db.execute(select(AuditLog.action)).scalars().all()
|
||||
assert (
|
||||
"user_activate" in actions
|
||||
) # the admin acted on someone's access; it is not a silent read
|
||||
|
||||
|
||||
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
|
||||
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
|
||||
# only checks is_active and would let them in, while their Google sign-in would be turned away
|
||||
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
|
||||
# in"; this one only finishes a decision already made.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "not_allowed"
|
||||
db.expire_all()
|
||||
assert db.get(User, nobody.id).is_active is False
|
||||
|
||||
|
||||
def test_activating_twice_is_not_an_error(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
client = as_user(admin)
|
||||
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
|
||||
|
||||
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
|
||||
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
|
||||
|
||||
assert "banned@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
# --- the confirmation resend ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app.routes import admin as admin_routes
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
|
||||
sent: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
auth_routes.email_mod,
|
||||
"send_verify_email",
|
||||
lambda to, lang, link, **kw: sent.append((to, link)) is None,
|
||||
)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
|
||||
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
|
||||
# with. The approved invite is what puts them on this screen at all: the list is "the address may
|
||||
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
|
||||
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
|
||||
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
|
||||
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
|
||||
assert [t.kind for t in tokens] == ["verify"]
|
||||
|
||||
|
||||
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
|
||||
from app.routes import admin as admin_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
|
||||
# to fall back on. An admin pressing a button gets the failure, not a success toast.
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
|
||||
|
||||
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="fine@example.com", password=_PW)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
|
||||
|
||||
|
||||
# --- the badge -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
|
||||
db, make_user, client
|
||||
):
|
||||
# Signed in for real, not via the as_user override: /api/me resolves its user through
|
||||
# optional_current_user, which that override does not cover (see test_me_email_flag).
|
||||
admin = make_user(email="admin@example.com", password=_PW, role="admin")
|
||||
_stuck_registrant(db, make_user, admin) # approved first → counts
|
||||
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": admin.email, "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
me = client.get("/api/me").json()
|
||||
assert me["authenticated"] is True
|
||||
|
||||
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
|
||||
# would be nagging about something the admin did not cause.
|
||||
assert me["pending_activations"] == 1
|
||||
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests
|
||||
@@ -97,28 +97,85 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
|
||||
assert replay.status_code == 400
|
||||
|
||||
|
||||
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
|
||||
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
|
||||
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
|
||||
# password a working login — it is dropped and any session it minted is revoked.
|
||||
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
|
||||
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
|
||||
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
|
||||
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
|
||||
# before the user reads their mail — making "your password was cleared for security" the default
|
||||
# experience.
|
||||
#
|
||||
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account
|
||||
# before it reads is_active. The epic's closing review refuted that: the gate holds only while
|
||||
# the address is unverified, and S6's approval mail exists to get the real owner to end that
|
||||
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2
|
||||
# of the review tried and reverted a guard for it (see test_review_round.py).
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
victim = make_user(
|
||||
email="victim@example.com",
|
||||
password="attacker-chosen",
|
||||
user = make_user(
|
||||
email="approved-first@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
epoch0 = victim.session_epoch or 0
|
||||
_activate_user_by_email(db, "victim@example.com")
|
||||
assert victim.is_active is True
|
||||
assert victim.password_hash is None
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1
|
||||
epoch0 = user.session_epoch or 0
|
||||
|
||||
_activate_user_by_email(db, "approved-first@example.com")
|
||||
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None
|
||||
assert user.password_revoked_at is None
|
||||
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
|
||||
|
||||
|
||||
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
|
||||
# The gate that stops a pre-emptor BEFORE the address is confirmed, whatever the admin decided.
|
||||
# It is not the whole story — confirming the address opens it, which is SEC-16 — but it is the
|
||||
# invariant this test owns, and a change that let an unverified account sign in would be a
|
||||
# straightforwardly worse bug than the residual.
|
||||
make_user(
|
||||
email="notyet@example.com",
|
||||
password="attacker-chosen",
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
r = _login(client, "notyet@example.com", "attacker-chosen")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "email_unverified"
|
||||
|
||||
|
||||
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
|
||||
# The payoff for the legitimate registrant: the password chosen at registration still works once
|
||||
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="ordering@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
verified = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert verified.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "ordering@example.com",
|
||||
}
|
||||
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
|
||||
|
||||
|
||||
def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
||||
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
|
||||
# approval — the guard must not break the normal flow.
|
||||
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
|
||||
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
user = make_user(
|
||||
@@ -151,7 +208,9 @@ def _no_smtp(monkeypatch):
|
||||
notified: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_admin_new_request", lambda admins, to: notified.append(to) or True
|
||||
auth_mod.email_mod,
|
||||
"send_admin_new_request",
|
||||
lambda admins, lang, to: notified.append(to) or True,
|
||||
)
|
||||
return notified
|
||||
|
||||
@@ -212,7 +271,7 @@ def test_registration_with_an_already_pending_request_persists_the_account(
|
||||
def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
|
||||
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
|
||||
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
|
||||
# _activate_user_by_email, which is also what keeps the SEC-10 preset-password guard in play.
|
||||
# _activate_user_by_email.
|
||||
from app.models import Invite
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
@@ -225,7 +284,7 @@ def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
|
||||
|
||||
db.expire_all()
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None # a verified account keeps its own password
|
||||
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
|
||||
|
||||
|
||||
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
|
||||
@@ -271,8 +330,10 @@ def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db,
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_verify_email", lambda to, link: sent.append(to))
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, to: True)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to)
|
||||
)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True)
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
@@ -346,11 +407,24 @@ def test_register_route_is_refused_when_registration_is_closed(client, db, monke
|
||||
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
|
||||
#
|
||||
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
|
||||
# approved by an admin BEFORE its email was verified, ended up with password_hash = NULL. That is
|
||||
# also what a Google-only account looks like, and reset eligibility keyed off `password_hash` alone —
|
||||
# so the account could neither sign in (no password) nor obtain a reset link, and every response on
|
||||
# the way was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two
|
||||
# apart; these pin each step of the way back in.
|
||||
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
|
||||
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
|
||||
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
|
||||
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
|
||||
# these pin each step of the way back in.
|
||||
#
|
||||
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
|
||||
# this state — plus every account stripped on prod before that change, which is why the whole recovery
|
||||
# route below must keep working. These tests manufacture the state through the surviving producer.
|
||||
|
||||
|
||||
def _revoke_as_google_adoption(db, user) -> None:
|
||||
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
|
||||
Google callback adopting an account whose email was never verified."""
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
|
||||
revoke_preset_password_if_unverified(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
@@ -358,13 +432,15 @@ def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to))
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def test_the_approval_revoke_stamps_password_revoked_at(db, make_user):
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
|
||||
# The guard still fires where it earns its keep: the real owner has just proven ownership through
|
||||
# Google, so a password set on the address while it was unverified must not survive.
|
||||
victim = make_user(
|
||||
email="stamped@example.com",
|
||||
password="attacker-chosen",
|
||||
@@ -372,27 +448,28 @@ def test_the_approval_revoke_stamps_password_revoked_at(db, make_user):
|
||||
is_active=False,
|
||||
)
|
||||
assert victim.password_revoked_at is None
|
||||
epoch0 = victim.session_epoch or 0
|
||||
|
||||
_activate_user_by_email(db, "stamped@example.com")
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
|
||||
assert victim.password_hash is None
|
||||
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
|
||||
|
||||
|
||||
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
make_user(
|
||||
victim = make_user(
|
||||
email="lockedout@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_activate_user_by_email(db, "lockedout@example.com")
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("lockedout@example.com")
|
||||
@@ -414,16 +491,15 @@ def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkey
|
||||
|
||||
|
||||
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="recovered@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
is_active=True, # already approved; reset-confirm verifies the email but never activates
|
||||
)
|
||||
_activate_user_by_email(db, "recovered@example.com")
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
@@ -442,7 +518,6 @@ def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db,
|
||||
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
|
||||
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
|
||||
# password" and has no way to learn why the password they chose stopped working.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
@@ -451,14 +526,18 @@ def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db,
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_activate_user_by_email(db, "needspw@example.com")
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "password_reset_required": True}
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": True,
|
||||
"email": "needspw@example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
|
||||
@@ -471,4 +550,8 @@ def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_u
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "password_reset_required": False}
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "intact@example.com",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
@@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present():
|
||||
"This video is unavailable"
|
||||
)
|
||||
assert classify(msg) == "login_required"
|
||||
|
||||
|
||||
def test_our_own_worker_refusals_classify_as_themselves():
|
||||
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
|
||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||
|
||||
|
||||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||||
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
|
||||
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
|
||||
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
|
||||
assert dlerrors.user_error_text("youtube", raw, "network") == raw
|
||||
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
|
||||
login = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||||
assert dlerrors.user_error_text("url", login, "login_required") == login
|
||||
|
||||
@@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable.
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, quota
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
@@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
||||
rows = dl.list_downloads(user=u, db=db)
|
||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||
|
||||
|
||||
# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------
|
||||
|
||||
|
||||
def _sized_asset(db, size_bytes, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=ref,
|
||||
format_sig="s",
|
||||
status="ready",
|
||||
ref_count=1,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def test_remaining_bytes_is_none_for_an_unlimited_user(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=2, unlimited=True)
|
||||
db.flush()
|
||||
# None, not a number: "no cap" and "no budget left" must never be the same value downstream —
|
||||
# build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart.
|
||||
assert quota.remaining_bytes(db, u.id) is None
|
||||
|
||||
|
||||
def test_remaining_bytes_is_the_unspent_part_of_the_cap(db):
|
||||
u = User(email="partial@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 400, "partial-1")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 600
|
||||
|
||||
|
||||
def test_remaining_bytes_is_zero_once_the_cap_is_met(db):
|
||||
# THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap,
|
||||
# and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever
|
||||
# returned the raw negative or None instead, the user already over quota would be the only one
|
||||
# downloading with no size ceiling at all.
|
||||
u = User(email="atcap@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 0
|
||||
|
||||
|
||||
def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path):
|
||||
"""A user at their cap fails their own job, and another user's queued job for the same video
|
||||
still downloads.
|
||||
|
||||
MediaAssets are deduped across users, and the generic failure handler writes the message onto
|
||||
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
||||
`worker._process_job` would then hand verbatim to someone with an empty library.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
over, other = User(email="over@example.com"), User(email="other@example.com")
|
||||
db.add_all([over, other])
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False
|
||||
)
|
||||
)
|
||||
full = _sized_asset(db, 1500, "already-held")
|
||||
_job(db, over.id, queue_pos=0, status="done", asset_id=full.id)
|
||||
|
||||
# `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before
|
||||
# the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this
|
||||
# assertion would pass with the release deleted.
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=2,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id)
|
||||
db.commit()
|
||||
|
||||
# The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening
|
||||
# first, this test starts reaching the network, which is its own signal.
|
||||
pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, mine.id).status == "error"
|
||||
assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded"
|
||||
# The asset is released, NOT marked error — otherwise the next line's job inherits the refusal.
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
assert db.get(MediaAsset, shared.id).error is None
|
||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_a_skipped_email_notifies_every_admin(db, make_user):
|
||||
admin_b = make_user(email="admin-b@example.com", role="admin")
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False # fail-soft, as before
|
||||
assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before
|
||||
|
||||
db.expire_all()
|
||||
for admin in (admin_a, admin_b):
|
||||
@@ -57,7 +57,7 @@ def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
for _ in range(5):
|
||||
email_mod.send_test("someone@example.com")
|
||||
email_mod.send_test("someone@example.com", "en")
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -84,7 +84,7 @@ def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_us
|
||||
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
(n,) = _notifications(db, admin.id)
|
||||
@@ -96,7 +96,7 @@ def test_no_admins_means_no_notification_and_no_error(db, make_user):
|
||||
# rather than blowing up inside the fail-soft path it is supposed to make visible.
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert _notifications(db, plain.id) == []
|
||||
@@ -106,10 +106,10 @@ def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
|
||||
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
|
||||
# during setup — before any admin exists — silences the next hour for the admin created a minute
|
||||
# later, which is exactly the blind spot this alert exists to remove.
|
||||
email_mod.send_test("someone@example.com") # no admins yet
|
||||
email_mod.send_test("someone@example.com", "en") # no admins yet
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -127,10 +127,10 @@ def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch
|
||||
return real(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
|
||||
email_mod.send_test("someone@example.com") # the write blows up; nothing recorded
|
||||
email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded
|
||||
boom["on"] = False
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -146,4 +146,4 @@ def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3).
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate
|
||||
used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent
|
||||
SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the
|
||||
account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account
|
||||
cannot contain `+`, so OAuth can only ever answer with the base address.
|
||||
|
||||
The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not
|
||||
hold for Google Workspace custom domains unless their admin enabled it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
|
||||
_UAT_PW = "correct horse battery"
|
||||
|
||||
from app.auth import is_allowed
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold.
|
||||
CASES = [
|
||||
# Gmail: dots and +tags are noise…
|
||||
("npeter83.temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("npeter83temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"),
|
||||
("user+siftlode@gmail.com", "user@gmail.com"),
|
||||
("u.ser+a+b@gmail.com", "user@gmail.com"),
|
||||
("USer.Name@GMail.COM", "username@gmail.com"),
|
||||
# …and googlemail is the same mailbox as gmail.
|
||||
("user.name@googlemail.com", "username@gmail.com"),
|
||||
# Everyone else: lowercase ONLY. Dots stay significant.
|
||||
("first.last@example.com", "first.last@example.com"),
|
||||
("First.Last@Example.COM", "first.last@example.com"),
|
||||
("first+tag@example.com", "first+tag@example.com"),
|
||||
# A Workspace domain is not Gmail, however Gmail-ish it looks.
|
||||
("first.last@gmail.company.com", "first.last@gmail.company.com"),
|
||||
# Degenerate input must not explode or invent an address.
|
||||
("", ""),
|
||||
(None, ""),
|
||||
("not-an-email", "not-an-email"),
|
||||
(".@gmail.com", ".@gmail.com"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", CASES)
|
||||
def test_canonical_email(raw, expected):
|
||||
assert canonical_email(raw) == expected
|
||||
|
||||
|
||||
def test_the_migration_sql_agrees_with_the_helper(db):
|
||||
"""The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code
|
||||
that keeps changing). That duplication is only safe while the two agree, so pin them together —
|
||||
a drift here means rows backfilled into a form the application will never look up."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py"
|
||||
spec = importlib.util.spec_from_file_location("m0079", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
sql = module._CANON_SQL.format(col=":addr")
|
||||
for raw, expected in CASES:
|
||||
if raw is None:
|
||||
continue # the column is NOT NULL; the backfill skips NULL emails explicitly
|
||||
got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one()
|
||||
assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}"
|
||||
|
||||
|
||||
def test_the_column_is_kept_in_sync_by_writing_the_email(db):
|
||||
"""No write site has to remember: the model validator derives it. If this breaks, every lookup
|
||||
below silently stops matching."""
|
||||
user = User(email="Dotted.Name+tag@GoogleMail.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here
|
||||
assert user.email_canonical == "dottedname@gmail.com" # …matched on this
|
||||
|
||||
user.email = "someone.else@example.com"
|
||||
db.commit()
|
||||
assert user.email_canonical == "someone.else@example.com"
|
||||
|
||||
|
||||
def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db):
|
||||
# THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with
|
||||
# the primary Google returns.
|
||||
db.add(Invite(email="npeter83.temu@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "npeter83temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db):
|
||||
# The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the
|
||||
# base address, and a literal match could never find the invite.
|
||||
db.add(Invite(email="someone+siftlode@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "someone@gmail.com") is True
|
||||
|
||||
|
||||
def test_dots_stay_significant_outside_gmail(db):
|
||||
db.add(Invite(email="first.last@example.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "first.last@example.com") is True
|
||||
assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere
|
||||
|
||||
|
||||
def test_an_approved_row_wins_over_a_denied_sibling(db):
|
||||
# Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved
|
||||
# invite to denied, so approved-beside-denied means the admin approved that mailbox under another
|
||||
# spelling — not that they revoked it. Revoking is deleting the row or suspending the account.
|
||||
db.add(Invite(email="two.spellings@gmail.com", status="denied"))
|
||||
db.add(Invite(email="twospellings@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "twospellings@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_denied_mailbox_stays_denied(db):
|
||||
db.add(Invite(email="no.thanks@gmail.com", status="denied"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "nothanks@gmail.com") is False
|
||||
|
||||
|
||||
def test_a_second_spelling_does_not_open_a_second_access_request(db):
|
||||
"""The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user
|
||||
who was already approved."""
|
||||
from app.routes.auth import upsert_pending_invite
|
||||
|
||||
db.add(Invite(email="already.approved@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "alreadyapproved@gmail.com")
|
||||
|
||||
assert req.is_new is False # nothing new to notify an admin about
|
||||
assert req.invite is not None and req.invite.email == "already.approved@gmail.com"
|
||||
assert len(db.execute(select(Invite)).scalars().all()) == 1
|
||||
|
||||
|
||||
def test_manually_whitelisting_another_spelling_reuses_the_row(db):
|
||||
"""How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same
|
||||
day: the admin typed the other spelling and got a second row instead of a no-op."""
|
||||
from app.routes.admin import add_invite, AddInviteIn
|
||||
|
||||
admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True)
|
||||
db.add(admin)
|
||||
db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db)
|
||||
|
||||
rows = db.execute(select(Invite)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
# --- signing in and recovering an account, whichever spelling of it the person types -------------
|
||||
#
|
||||
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
|
||||
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
|
||||
# total silence from password-reset and verify-resend, which answer uniformly by design.
|
||||
|
||||
|
||||
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
make = User(
|
||||
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
|
||||
)
|
||||
db.add(make)
|
||||
db.commit()
|
||||
|
||||
for typed in (
|
||||
"t.e.st+tag@gmail.com", # as registered
|
||||
"test@gmail.com", # the mailbox itself
|
||||
"t.e.st@gmail.com", # dots only
|
||||
"TEST@googlemail.com", # the alias domain
|
||||
):
|
||||
assert find_user_by_email(db, typed) is make, typed
|
||||
|
||||
|
||||
def test_an_unrelated_address_still_finds_nothing(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "someoneelse@gmail.com") is None
|
||||
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
|
||||
|
||||
|
||||
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
|
||||
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
|
||||
# their own account, not on whichever row happens to come first.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
|
||||
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
|
||||
db.add_all([dotted, plain])
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
|
||||
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
|
||||
|
||||
|
||||
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
|
||||
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
|
||||
# them at random would let someone into the wrong account, so this answers "no such account" —
|
||||
# the same uniform reply the caller already gives.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
|
||||
|
||||
|
||||
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
|
||||
"""Typing another spelling must still reach the owner — and the mail must go to the address they
|
||||
registered with, tag and all, not to the one typed into the form."""
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
db.add(
|
||||
User(
|
||||
email="me+siftlode@gmail.com",
|
||||
email_verified=True,
|
||||
is_active=True,
|
||||
password_hash="x",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("me@gmail.com")
|
||||
|
||||
assert sent == ["me+siftlode@gmail.com"]
|
||||
|
||||
|
||||
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
|
||||
|
||||
|
||||
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
|
||||
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
|
||||
Google returns the account's primary address, which can never contain `+`, so following it
|
||||
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
|
||||
address first, stopped them signing in with the address they had chosen."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com"
|
||||
|
||||
|
||||
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
|
||||
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "brand-new@example.com")
|
||||
|
||||
assert user.email == "brand-new@example.com"
|
||||
|
||||
|
||||
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
|
||||
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "taken@example.com")
|
||||
|
||||
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
|
||||
|
||||
|
||||
# --- showing BOTH addresses, once there are two -------------------------------------------------
|
||||
|
||||
|
||||
def test_the_google_address_is_recorded_beside_the_registered_one(db):
|
||||
"""Since the sign-in no longer overwrites the registered address, the account has two true
|
||||
addresses and only one was stored. This is the other half, for the UI to name."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
user.google_email = "me@gmail.com"
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
db.commit()
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com" # still theirs
|
||||
assert user.google_email == "me@gmail.com" # …and now we know the other one
|
||||
|
||||
|
||||
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
|
||||
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
|
||||
stays exactly as it is for the majority, whose two addresses are the same string."""
|
||||
same = make_user(email="plain@gmail.com", password=_UAT_PW)
|
||||
same.google_email = "plain@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] is None
|
||||
|
||||
client.post("/auth/logout")
|
||||
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
|
||||
tagged.google_email = "me@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
|
||||
|
||||
|
||||
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
|
||||
|
||||
|
||||
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
|
||||
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
|
||||
person. Resolving by the literal string here would create a SECOND admin row — exactly the
|
||||
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
|
||||
|
||||
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
|
||||
test, and the test fixtures present an already-configured instance."""
|
||||
from app.routes.setup import SetupAdminIn, setup_admin
|
||||
|
||||
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
|
||||
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
|
||||
|
||||
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
|
||||
assert len(admins) == 1, [u.email for u in admins]
|
||||
# The address stays AS TYPED on the row it matched; only identity is canonical.
|
||||
assert admins[0].email == "a.b.c@gmail.com"
|
||||
# And the second run's password is the live one — it updated, it did not fork.
|
||||
from app.security import verify_password
|
||||
|
||||
assert verify_password("another good passphrase", admins[0].password_hash)
|
||||
@@ -103,7 +103,9 @@ def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
|
||||
|
||||
assert (
|
||||
email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok")
|
||||
email_mod.send_verify_email(
|
||||
"someone@example.com", "en", "https://siftlode.example/#verify=tok"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability():
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
def test_a_playlist_url_can_only_yield_one_item():
|
||||
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
|
||||
# purely a playlist or channel has no such video, so without this it expands into every entry.
|
||||
opts = _opts({})
|
||||
assert opts["noplaylist"] is True
|
||||
assert opts["playlist_items"] == "1"
|
||||
|
||||
|
||||
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
|
||||
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_no_budget_means_no_size_filter():
|
||||
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
|
||||
# enqueue, not an impossible yt-dlp filter to express here.
|
||||
for budget in (None, 0, -1):
|
||||
assert "max_filesize" not in formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, max_bytes=budget
|
||||
)
|
||||
|
||||
|
||||
def test_a_source_adapter_cannot_raise_the_size_ceiling():
|
||||
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
|
||||
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
|
||||
opts = formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
|
||||
)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_a_join_cut_list_is_capped():
|
||||
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
|
||||
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
|
||||
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == editmod._MAX_SEGMENTS
|
||||
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
|
||||
|
||||
|
||||
def test_a_normal_cut_list_is_untouched():
|
||||
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == 2
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""What a refused sign-in SAYS (E9.5 S4), DB-lane.
|
||||
|
||||
The states behind a 403 were true and useless: "Your account is awaiting admin approval." never
|
||||
revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com`
|
||||
met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day.
|
||||
So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made
|
||||
the two order-independent and the screen must report whichever is actually outstanding.
|
||||
|
||||
The two refusals that answer BEFORE the password is proven carry nothing, and that is the security
|
||||
half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Invite
|
||||
from app.routes.auth import _access_notice_url, upsert_pending_invite
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _login(client, email, password=_PW):
|
||||
return client.post("/auth/password-login", json={"email": email, "password": password})
|
||||
|
||||
|
||||
def test_pending_approval_names_the_address_the_account_is_on(client, make_user):
|
||||
# The typed spelling and the stored one are the same mailbox; the screen must show the STORED
|
||||
# one, because that is the mismatch a user cannot otherwise see.
|
||||
make_user(email="np.temu@gmail.com", password=_PW, is_active=False)
|
||||
|
||||
detail = _login(client, "nptemu@gmail.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "pending_approval"
|
||||
assert detail["email"] == "np.temu@gmail.com"
|
||||
assert detail["email_verified"] is True and detail["approved"] is False
|
||||
|
||||
|
||||
def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user):
|
||||
# S1 made the gates order-independent, so this really happens: approved, not yet verified. A
|
||||
# screen that assumed a sequence would tell this user to wait for an admin who already decided.
|
||||
make_user(
|
||||
email="approved.first@example.com", password=_PW, email_verified=False, is_active=True
|
||||
)
|
||||
|
||||
detail = _login(client, "approved.first@example.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "email_unverified"
|
||||
assert detail["email_verified"] is False and detail["approved"] is True
|
||||
|
||||
|
||||
def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "ops@example.com")
|
||||
make_user(email="banned@example.com", password=_PW, is_suspended=True)
|
||||
|
||||
r = _login(client, "banned@example.com")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "suspended"
|
||||
assert r.json()["detail"]["operator"] == "ops@example.com"
|
||||
|
||||
|
||||
def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user):
|
||||
make_user(email="real@example.com", password=_PW, is_active=False)
|
||||
|
||||
wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"]
|
||||
unknown = _login(client, "nobody@example.com").json()["detail"]
|
||||
|
||||
# No address, no flags — the pending state above must not leak through the wrong-password door.
|
||||
assert wrong_pw == unknown == {"code": "login", "reason": "invalid"}
|
||||
|
||||
|
||||
# --- the access-request notice -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_access_notice_names_the_address_and_rides_the_fragment():
|
||||
url = _access_notice_url("requested", "someone@example.com")
|
||||
|
||||
# A fragment never reaches a proxy access log or a Referer header — the same reasoning that put
|
||||
# the verify/reset tokens there (SB3), applied to an address.
|
||||
assert url == "/#access=requested&email=someone%40example.com"
|
||||
|
||||
|
||||
def test_a_plus_tagged_address_survives_the_round_trip():
|
||||
# URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or
|
||||
# the very address family this epic exists to protect arrives mangled.
|
||||
url = _access_notice_url("requested", "me+siftlode@gmail.com")
|
||||
|
||||
assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url
|
||||
|
||||
|
||||
def test_a_request_already_on_file_under_another_spelling_says_so(db):
|
||||
db.add(Invite(email="hartmann.peter@gmail.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "hartmannpeter@gmail.com")
|
||||
url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite)
|
||||
|
||||
assert req.is_new is False # one mailbox, one request — no second row for the admin queue
|
||||
assert "linked=hartmann.peter%40gmail.com" in url
|
||||
|
||||
|
||||
def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db):
|
||||
db.add(Invite(email="same@example.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "same@example.com")
|
||||
|
||||
# Nothing to explain: telling this user we "linked" their request to itself would be noise.
|
||||
assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""E9.5 S7 — every outbound mail is written in the reader's language.
|
||||
|
||||
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
|
||||
here because a mail is the one user-facing surface nobody can re-render by switching the UI language
|
||||
— it arrives once, in whatever language we picked. The second is the resolution chain: which
|
||||
language a given recipient actually gets, and where that signal was captured, since for an address
|
||||
with no account it exists only at the moment they asked us.
|
||||
"""
|
||||
|
||||
import re
|
||||
import string
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import mailtext
|
||||
from app.models import Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _placeholders(template: str) -> set[str]:
|
||||
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
|
||||
|
||||
|
||||
# --- the catalog itself ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_mail_string_exists_in_both_languages(key):
|
||||
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_translation_takes_the_same_placeholders(key):
|
||||
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
|
||||
# in it, and a translation that invents one raises KeyError inside a background task nobody is
|
||||
# watching. Both are silent in production and loud here.
|
||||
variants = mailtext.MAIL_TEXT[key]
|
||||
expected = _placeholders(variants[mailtext.DEFAULT])
|
||||
for lang, template in variants.items():
|
||||
assert _placeholders(template) == expected, f"{key} [{lang}]"
|
||||
|
||||
|
||||
def _app_sources_without_the_catalog() -> str:
|
||||
"""Every app source, with the catalog LITERAL cut out of mailtext.py.
|
||||
|
||||
Cutting it is the whole point: the dict spells each key as a quoted literal, so a scan that
|
||||
included it reported every key as used no matter what the catalog held. Cutting the whole FILE
|
||||
is too much, though — `link_expiry` legitimately asks for `duration.*`, and those keys would
|
||||
then look orphaned."""
|
||||
from pathlib import Path
|
||||
|
||||
app_dir = Path(__file__).resolve().parents[1] / "app"
|
||||
out = []
|
||||
for f in app_dir.rglob("*.py"):
|
||||
src = f.read_text(encoding="utf-8")
|
||||
if f.name == "mailtext.py":
|
||||
src = src[: src.index("MAIL_TEXT: dict")]
|
||||
out.append(src)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def test_no_catalog_entry_is_orphaned():
|
||||
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
|
||||
behind to be 'kept up to date' by the next person who greps for it."""
|
||||
sources = _app_sources_without_the_catalog()
|
||||
assert [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] == []
|
||||
|
||||
|
||||
def test_every_key_the_app_asks_for_exists():
|
||||
"""The other direction, and the one that raises in production: `text()` looks the key up in a
|
||||
dict, so a renamed or mistyped key is a KeyError inside a background task with no user in sight."""
|
||||
asked = set(re.findall(r'text\(\s*"([a-z_]+\.[a-z_]+)"', _app_sources_without_the_catalog()))
|
||||
assert asked, "the scan found no mailtext.text(...) call sites — the pattern has rotted"
|
||||
assert sorted(asked - set(mailtext.MAIL_TEXT)) == []
|
||||
|
||||
|
||||
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
|
||||
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
|
||||
# shipping. A send that raised on one would take out a background task with no user in sight.
|
||||
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
|
||||
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
|
||||
|
||||
|
||||
# --- who gets which language ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_users_own_preference_decides_their_language(make_user):
|
||||
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
|
||||
en = make_user(email="en@example.com", preferences={"language": "en"})
|
||||
none = make_user(email="none@example.com", preferences=None)
|
||||
|
||||
assert mailtext.lang_for_user(hu) == "hu"
|
||||
assert mailtext.lang_for_user(en) == "en"
|
||||
assert mailtext.lang_for_user(none) == "en"
|
||||
|
||||
|
||||
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
|
||||
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
|
||||
|
||||
|
||||
def test_the_account_wins_over_the_invite_row(db, make_user):
|
||||
make_user(email="both@example.com", preferences={"language": "en"})
|
||||
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
# The invite records how they asked once; the account records what they have since chosen.
|
||||
assert mailtext.lang_for_email(db, "both@example.com") == "en"
|
||||
|
||||
|
||||
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
|
||||
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
|
||||
# row would answer English for a person whose other account says Hungarian.
|
||||
make_user(email="dup.one@gmail.com", preferences={})
|
||||
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
|
||||
|
||||
|
||||
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
|
||||
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
|
||||
|
||||
|
||||
# --- capturing the signal at the one moment it exists ---------------------------------------------
|
||||
|
||||
|
||||
def test_registration_records_the_language_of_the_sign_up_page(db, client):
|
||||
r = client.post(
|
||||
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
|
||||
assert (user.preferences or {}).get("language") == "hu"
|
||||
|
||||
|
||||
def test_an_access_request_records_the_language_it_was_made_in(db, client):
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang == "hu"
|
||||
|
||||
|
||||
def test_asking_again_from_another_language_updates_the_row(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
|
||||
db.expire_all()
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
|
||||
assert inv.lang == "hu"
|
||||
assert db.execute(select(Invite)).scalars().all() == [inv]
|
||||
|
||||
|
||||
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang is None
|
||||
|
||||
|
||||
# --- the mails themselves -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bodies(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
|
||||
the language only matters if it reaches the body."""
|
||||
from app import email as email_mod
|
||||
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
|
||||
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
|
||||
|
||||
(_, subject, body) = sent[0]
|
||||
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
|
||||
assert "#verify=" in body
|
||||
|
||||
|
||||
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
|
||||
db, client, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear() # drop the admin notification; this test is about the reply to the requester
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
(to, subject, _) = sent[0]
|
||||
assert to == ["asking@example.com"]
|
||||
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
|
||||
|
||||
|
||||
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear()
|
||||
|
||||
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
|
||||
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
|
||||
|
||||
|
||||
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
|
||||
# the send button asks.
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
|
||||
db.expire_all()
|
||||
# Recorded, so a later mail to this address matches the one the admin already sent.
|
||||
assert db.get(Invite, inv.id).lang == "hu"
|
||||
|
||||
|
||||
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
|
||||
db.expire_all()
|
||||
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
|
||||
# not "what we last defaulted to".
|
||||
assert db.get(Invite, inv.id).lang is None
|
||||
|
||||
|
||||
def test_admins_are_notified_in_their_own_languages(db, make_user):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
groups = auth_routes.admin_notify_groups(db)
|
||||
|
||||
# One mail per language, not one blast that has to be wrong for somebody.
|
||||
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane.
|
||||
|
||||
Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full
|
||||
"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after
|
||||
the user typed a password did the server answer 400. No security hole (the account's tokens cascade
|
||||
away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly
|
||||
the kind this epic exists to remove.
|
||||
|
||||
The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their
|
||||
one real attempt. That is the property most worth pinning here.
|
||||
"""
|
||||
|
||||
from app.routes.auth import RESET_TTL, _issue_token
|
||||
|
||||
_PW = "correct horse battery"
|
||||
_NEW = "brand new password"
|
||||
|
||||
|
||||
def _issue(db, user):
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def test_checking_a_live_link_does_not_spend_it(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="live@example.com", password=_PW))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
|
||||
# The whole point: after any number of checks the one real attempt is still there.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user):
|
||||
user = make_user(email="gone@example.com", password=_PW)
|
||||
raw = _issue(db, user)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
# And the confirm agrees, in the structured shape the card renders as a named screen — the two
|
||||
# must never disagree about one token.
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "reset", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_a_spent_link_is_reported_dead(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
|
||||
client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_too_short_password_does_not_burn_the_link(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="short@example.com", password=_PW))
|
||||
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "weak_password"
|
||||
# A mistyped password must not cost the link — the length check runs BEFORE the token is spent,
|
||||
# and the card tells the two failures apart by `reason` so only one of them takes the form down.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_dead_verification_link_answers_in_the_same_structured_shape(client):
|
||||
r = client.post("/auth/verify", json={"token": "no-such-token"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "verify", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_the_check_never_reports_a_live_link_dead_when_throttled(
|
||||
client, db, make_user, monkeypatch
|
||||
):
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
raw = _issue(db, make_user(email="throttled@example.com", password=_PW))
|
||||
monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False)
|
||||
|
||||
# Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead"
|
||||
# would lock someone out of a link that works.
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""E9.5 review round — the invariants the epic's own review found unpinned.
|
||||
|
||||
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
|
||||
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
|
||||
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
|
||||
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
|
||||
covered only while the address stayed unverified.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import auth as auth_core
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app.config import settings
|
||||
from app.models import AuthToken, Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
|
||||
never renders in a test is a body that first renders in someone's inbox."""
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_on(monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
# --- every mail actually renders ------------------------------------------------------------------
|
||||
|
||||
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
|
||||
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
|
||||
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
|
||||
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
|
||||
# that executed their `.format()` at all.
|
||||
_EVERY_MAIL = [
|
||||
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_denied", ("to@example.com", "LANG")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "user", None)),
|
||||
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_deleted", ("to@example.com", "LANG", None)),
|
||||
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_suspended", ("to@example.com", "LANG", None)),
|
||||
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
|
||||
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
|
||||
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
|
||||
("send_test", ("to@example.com", "LANG")),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
|
||||
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
|
||||
def test_every_mail_renders_in_every_language(wire, name, args, lang):
|
||||
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
|
||||
|
||||
(_, subject, body) = wire[0]
|
||||
assert subject and body
|
||||
# A template that lost its interpolation renders the brace text verbatim.
|
||||
assert "{" not in subject and "{" not in body
|
||||
|
||||
|
||||
def test_a_weeks_link_is_described_in_days_not_hours(wire):
|
||||
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
|
||||
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
|
||||
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
|
||||
|
||||
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
|
||||
assert "24 óra" in wire[1][2]
|
||||
|
||||
|
||||
# --- what a second registration may and may not do -------------------------------------------------
|
||||
|
||||
|
||||
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
|
||||
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
|
||||
|
||||
"While the address is unverified, the newest registration owns it" looked like a fix for address
|
||||
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
|
||||
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
|
||||
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
|
||||
have installed a password where the whole point is that there is none. Reverted; the owner's
|
||||
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
|
||||
"""
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
before = user.password_hash
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).password_hash == before
|
||||
|
||||
|
||||
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# Not just a subset check below: `set() <= set()` is vacuously true, so without this the test
|
||||
# would go green while asserting nothing the day registration stops issuing a token.
|
||||
assert len(first) == 1
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
|
||||
assert set(first) <= set(live) and len(live) == len(first) + 1
|
||||
|
||||
|
||||
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
|
||||
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
|
||||
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
|
||||
clear it, nor to put a password on a row whose whole state is "there is none"."""
|
||||
stripped = make_user(
|
||||
email="stripped@example.com",
|
||||
password=None,
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
password_revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
row = db.get(User, stripped.id)
|
||||
assert row.password_hash is None and row.password_revoked_at is not None
|
||||
|
||||
|
||||
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
|
||||
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
|
||||
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
|
||||
# deterministic rather than merely possible.
|
||||
db.add_all(
|
||||
[
|
||||
Invite(email="pair.one@gmail.com", status="pending"),
|
||||
Invite(email="pairone@gmail.com", status="denied"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
|
||||
|
||||
db.expire_all()
|
||||
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
|
||||
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
|
||||
|
||||
|
||||
# --- the env bootstrap answers the same question as the gate --------------------------------------
|
||||
|
||||
|
||||
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
|
||||
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
|
||||
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
|
||||
|
||||
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
|
||||
|
||||
|
||||
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
|
||||
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
|
||||
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
|
||||
# env grant. With the env list matched canonically the request never opens a row.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
|
||||
|
||||
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
|
||||
|
||||
assert db.execute(select(Invite)).scalars().all() == []
|
||||
assert auth_core.is_allowed(db, "bob@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
|
||||
|
||||
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
|
||||
|
||||
|
||||
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
|
||||
|
||||
|
||||
def test_approving_one_spelling_activates_the_account_registered_under_another(
|
||||
db, make_user, as_user, smtp_on, wire
|
||||
):
|
||||
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
|
||||
# and the account exists under the other Gmail spelling of the same mailbox.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
|
||||
user.email = "dotless@gmail.com"
|
||||
inv = Invite(email="dot.less@gmail.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).is_active is True
|
||||
|
||||
|
||||
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
|
||||
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
|
||||
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
doomed = make_user(email="pair.one@gmail.com", password=_PW)
|
||||
make_user(email="pairone@gmail.com", password=_PW)
|
||||
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
|
||||
|
||||
|
||||
# --- a button an admin pressed must not report a send that did not happen -------------------------
|
||||
|
||||
|
||||
def test_a_failed_invitation_changes_nothing_the_admin_was_told_about(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stranger = make_user(
|
||||
email="stranger@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
db.expire_all()
|
||||
# The column exists to answer "did I already write to them?", and the UI dims the button and
|
||||
# warns about a second mail on the strength of it. A dropped send must not make it say yes.
|
||||
assert db.get(Invite, inv.id).invited_at is None
|
||||
# And a request the UI reports as a failure must not have let somebody in behind the admin's
|
||||
# back — the activation belongs after a mail that actually left.
|
||||
assert db.get(User, stranger.id).is_active is False
|
||||
|
||||
|
||||
def test_a_failed_resend_says_so_instead_of_reporting_ok(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
|
||||
|
||||
# --- refusals and links that are already dead ------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
|
||||
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
|
||||
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
|
||||
user = make_user(email="late@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
|
||||
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
|
||||
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
|
||||
user = make_user(email="mixed@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
|
||||
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
|
||||
# account and an unknown one; only `invalid` was pinned.
|
||||
make_user(email="real@example.com", password=_PW)
|
||||
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
|
||||
|
||||
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
|
||||
unknown = client.post(
|
||||
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
|
||||
)
|
||||
|
||||
assert real.status_code == unknown.status_code == 429
|
||||
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
|
||||
|
||||
|
||||
# --- admins hear about a request in their own language --------------------------------------------
|
||||
|
||||
|
||||
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
|
||||
notices = [w for w in wire if "asking@example.com" in w[1]]
|
||||
assert len(notices) == 2
|
||||
by_recipient = {w[0][0]: w[1] for w in notices}
|
||||
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"hu"
|
||||
].format(requester="asking@example.com")
|
||||
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"en"
|
||||
].format(requester="asking@example.com")
|
||||
|
||||
|
||||
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
|
||||
|
||||
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""E12 · S5 — the admin-issued set-password link.
|
||||
|
||||
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
|
||||
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
|
||||
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
|
||||
|
||||
The property that must not regress is the one that makes it honest: an admin-issued link proves
|
||||
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
|
||||
confirm the address the way a mailed reset does.
|
||||
"""
|
||||
|
||||
from app.models import AuthToken
|
||||
|
||||
|
||||
def _link(client_admin, user_id: int):
|
||||
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["url"].split("#reset=", 1)[1]
|
||||
|
||||
|
||||
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
|
||||
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
|
||||
it proves ownership and confirms the address. An admin-issued link does not, and claiming
|
||||
otherwise would let an admin confirm an address nobody ever proved."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False)
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash is not None
|
||||
assert target.email_verified is False # the address is still unproven — and says so
|
||||
|
||||
|
||||
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
|
||||
"""The mutation check for the test above: if `email_verified` were simply never set, this would
|
||||
fail — and the rescue path for "registered but never clicked the link" would be gone."""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email_verified=False)
|
||||
db.commit()
|
||||
token = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(user)
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
|
||||
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
|
||||
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
|
||||
than writing a second one that could forget to bump."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
before = target.session_epoch
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
token = _link(as_user(admin), target.id)
|
||||
client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
|
||||
)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.session_epoch != before
|
||||
|
||||
|
||||
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
|
||||
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
|
||||
anything — which is also why it is safe to offer on a perfectly healthy account."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
original = target.password_hash
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
_link(as_user(admin), target.id)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash == original
|
||||
assert target.password_revoked_at is None
|
||||
|
||||
|
||||
def test_the_link_is_single_use(db, as_user, make_user, client):
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
first = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
|
||||
)
|
||||
second = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
|
||||
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
|
||||
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
first = _link(as_user(admin), target.id)
|
||||
second = _link(as_user(admin), target.id)
|
||||
|
||||
ok = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
|
||||
)
|
||||
assert stale.status_code == 400
|
||||
|
||||
|
||||
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
|
||||
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
|
||||
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
|
||||
onto "this link is spent"."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post("/auth/password-reset/check", json={"token": token})
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_only_an_admin_can_mint_one(db, as_user, make_user):
|
||||
plain = make_user()
|
||||
victim = make_user()
|
||||
db.commit()
|
||||
|
||||
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
|
||||
assert r.status_code in (401, 403)
|
||||
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
|
||||
|
||||
|
||||
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
|
||||
"""The demo account is shared, so handing anyone a way to change its password hands them
|
||||
everyone else's session on it."""
|
||||
admin = make_user(role="admin")
|
||||
demo = make_user(is_demo=True)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "demo"
|
||||
|
||||
|
||||
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False, is_active=False)
|
||||
db.commit()
|
||||
|
||||
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
|
||||
assert body["email_verified"] is False
|
||||
assert body["is_active"] is False
|
||||
assert set(body) >= {
|
||||
"is_allowed",
|
||||
"can_activate",
|
||||
"email_configured",
|
||||
"password_revoked_at",
|
||||
"google_email",
|
||||
"has_password",
|
||||
"has_google",
|
||||
}
|
||||
|
||||
|
||||
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
|
||||
""" "Connected" and "connected as someone else" are different statements, and only the second one
|
||||
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
|
||||
admin = make_user(role="admin")
|
||||
same = make_user(email="same@example.com")
|
||||
same.google_email = "same@example.com"
|
||||
other = make_user(email="typed@example.com")
|
||||
other.google_email = "google-primary@example.com"
|
||||
db.commit()
|
||||
|
||||
c = as_user(admin)
|
||||
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
|
||||
assert (
|
||||
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
|
||||
== "google-primary@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
|
||||
"""The audit log is read by humans and kept long; a live credential-setting link is the last
|
||||
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
|
||||
from app.models import AuditLog
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
|
||||
secret = url.split("#reset=", 1)[1]
|
||||
|
||||
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].summary == target.email
|
||||
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
|
||||
|
||||
|
||||
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
|
||||
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
|
||||
and narrowing this would only make it refuse exactly then."""
|
||||
admin = make_user(role="admin")
|
||||
google_only = make_user(password=None)
|
||||
db.commit()
|
||||
assert google_only.password_hash is None
|
||||
|
||||
token = _link(as_user(admin), google_only.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
db.refresh(google_only)
|
||||
assert google_only.password_hash is not None
|
||||
|
||||
|
||||
def test_an_unknown_user_is_a_404(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
|
||||
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
|
||||
|
||||
|
||||
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
|
||||
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
|
||||
someone a working link."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
raw = _link(as_user(admin), target.id)
|
||||
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].token_hash != raw
|
||||
assert raw not in rows[0].token_hash
|
||||
|
||||
|
||||
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
|
||||
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
|
||||
one hour is far too short."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
_link(as_user(admin), target.id)
|
||||
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
|
||||
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
|
||||
|
||||
|
||||
def test_users_listing_still_serializes(db, as_user, make_user):
|
||||
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
|
||||
endpoint exists to keep out of it."""
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
rows = as_user(admin).get("/api/admin/users").json()
|
||||
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
|
||||
assert "can_activate" not in rows[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""SEC-12 · the download URL guard.
|
||||
|
||||
The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches
|
||||
whatever the user typed. These pin that a URL resolving into our own network is refused, that the
|
||||
refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and
|
||||
that a normal public URL still goes through.
|
||||
|
||||
DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that
|
||||
resolves real names would be slow, offline-fragile, and would silently start passing if the guard
|
||||
stopped resolving at all.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def _stub_dns(monkeypatch, addrs):
|
||||
"""Make every lookup answer with `addrs` (a list of textual IPs)."""
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **kw):
|
||||
return [(None, None, None, "", (addr, port or 0)) for addr in addrs]
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
|
||||
def _blocked_reason(url):
|
||||
with pytest.raises(urlguard.BlockedUrl) as e:
|
||||
urlguard.check_url(url)
|
||||
return e.value.reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addr",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.1.2.3", # RFC1918
|
||||
"192.168.0.5", # RFC1918
|
||||
"172.16.9.9", # RFC1918
|
||||
"169.254.169.254", # link-local — the cloud metadata endpoint
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 unique-local
|
||||
"::ffff:127.0.0.1", # loopback wearing an IPv6 hat
|
||||
],
|
||||
)
|
||||
def test_addresses_on_our_own_network_are_refused(monkeypatch, addr):
|
||||
_stub_dns(monkeypatch, [addr])
|
||||
assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch):
|
||||
# A host answering with both a public and an internal address must not become a coin flip:
|
||||
# yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"])
|
||||
assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_a_public_address_passes(monkeypatch):
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
urlguard.check_url("https://example.com/watch?v=abc") # does not raise
|
||||
|
||||
|
||||
def test_unresolvable_is_its_own_reason(monkeypatch):
|
||||
import socket as _socket
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise _socket.gaierror("nope")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
# Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that"
|
||||
# are different things to tell a user, and the frontend renders them differently.
|
||||
assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"])
|
||||
def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url):
|
||||
def boom(*a, **kw): # a scheme rejection must not have resolved anything first
|
||||
raise AssertionError("DNS was consulted for a non-http scheme")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
assert _blocked_reason(url) == urlguard.REASON_SCHEME
|
||||
|
||||
|
||||
def test_a_url_without_a_host_is_refused():
|
||||
assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST
|
||||
|
||||
|
||||
def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch):
|
||||
# Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the
|
||||
# property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo
|
||||
# into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this
|
||||
# module exists to produce.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED
|
||||
@@ -0,0 +1,249 @@
|
||||
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
|
||||
|
||||
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
|
||||
another asking them to confirm their address. The C option carries the confirmation link inside the
|
||||
mail that is already going out, so the usual case is one mail and one click.
|
||||
|
||||
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
|
||||
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
|
||||
sent nothing at all, while answering with the same cheerful "check your inbox".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _capture(monkeypatch, name):
|
||||
"""Record the arguments a given email_mod.send_* was called with."""
|
||||
from app import email as email_mod
|
||||
|
||||
calls: list[tuple] = []
|
||||
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
|
||||
return calls
|
||||
|
||||
|
||||
def _smtp_on(monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
def _approve(client, invite_id):
|
||||
r = client.post(f"/api/admin/invites/{invite_id}/approve")
|
||||
assert r.status_code == 200
|
||||
return r
|
||||
|
||||
|
||||
# --- the approval mail branches ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(email, _lang, link, has_account, hours), _ = sent[0]
|
||||
assert email == "waiting@example.com" and has_account is True
|
||||
assert link and "#verify=" in link
|
||||
# The whole point: this is the ONLY message they now have to open.
|
||||
assert hours == 24 * 7
|
||||
|
||||
|
||||
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
_approve(as_user(admin), inv.id)
|
||||
token = sent[0][0][2].split("#verify=")[1]
|
||||
|
||||
r = as_user(admin).post("/auth/verify", json={"token": token})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).email_verified is True
|
||||
|
||||
|
||||
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
|
||||
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
|
||||
# before it is read, and the one-mail promise with it.
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
|
||||
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
|
||||
|
||||
|
||||
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="ready@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="ready@example.com", password=_PW, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
assert link is None and has_account is True # no link to offer, and none needed
|
||||
|
||||
|
||||
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
|
||||
# noticeable — the failure that went unnoticed on 2026-09-03.
|
||||
assert link is None and has_account is False
|
||||
|
||||
|
||||
# --- the deliberate invitation --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
approved = _capture(monkeypatch, "send_access_approved")
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
|
||||
|
||||
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
|
||||
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
|
||||
# asked to be nudged.
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is None
|
||||
assert approved == [] and invited == []
|
||||
|
||||
|
||||
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is not None
|
||||
assert invited[0][0][0] == "maybe@example.com"
|
||||
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
|
||||
# made — the wrong answer means mailing a real person twice.
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is not None
|
||||
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
|
||||
|
||||
|
||||
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="asking@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
|
||||
|
||||
|
||||
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
|
||||
|
||||
|
||||
# --- the silent register no-op --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/register", json={"email": "twice@example.com", "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert len(sent) == 1
|
||||
|
||||
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert len(sent) == 2
|
||||
assert sent[1][0][0] == "twice@example.com"
|
||||
|
||||
|
||||
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_verify_email")
|
||||
for _ in range(2):
|
||||
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
db.expire_all()
|
||||
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
make_user(email="done@example.com", password=_PW) # already verified
|
||||
|
||||
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
|
||||
|
||||
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
|
||||
# to someone who already did would be a lie.
|
||||
assert r.status_code == 200
|
||||
assert sent == []
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -74,6 +79,9 @@ services:
|
||||
worker:
|
||||
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -93,6 +101,8 @@ services:
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -114,6 +124,8 @@ services:
|
||||
volumes:
|
||||
siftlode_pgdata:
|
||||
siftlode_downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
siftlode_plex_media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
@@ -38,6 +38,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
@@ -57,6 +62,9 @@ services:
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
BUILD_DATE: ${BUILD_DATE:-}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
@@ -73,6 +81,8 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
@@ -85,3 +95,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
plex_media:
|
||||
|
||||
+188
-7
@@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
|
||||
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
|
||||
|
||||
## Plex library (optional)
|
||||
|
||||
If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube
|
||||
feed. Two things have to line up:
|
||||
|
||||
1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads
|
||||
your libraries.
|
||||
2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files
|
||||
directly rather than proxying Plex, so it has to be able to open them.
|
||||
|
||||
Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`:
|
||||
|
||||
```bash
|
||||
PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||
```
|
||||
|
||||
It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex
|
||||
reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`,
|
||||
means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to
|
||||
whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path
|
||||
still lines up.
|
||||
|
||||
Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and
|
||||
the module stays off.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
# Update to the latest release
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
|
||||
# Logs / status (api = web, worker = downloads/edits)
|
||||
docker compose -f docker-compose.selfhost.yml logs -f api
|
||||
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||
@@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
```
|
||||
|
||||
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
|
||||
Docker volume — back that up to keep your instance's state. Database migrations run automatically
|
||||
when the app starts, so updating is just pull + up.
|
||||
## Backups
|
||||
|
||||
**A backup is three things, not one.** Copying only the database gets you an instance that starts and
|
||||
then cannot read its own secrets.
|
||||
|
||||
1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you
|
||||
made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command
|
||||
below.
|
||||
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
|
||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||
against a different key and those values are lost — you would have to clear the affected rows from
|
||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or
|
||||
whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||
metadata are in the database, the files themselves are not.
|
||||
|
||||
Dump and restore the database with these two commands. They also exist as scripts
|
||||
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
|
||||
the source checkout:
|
||||
|
||||
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
|
||||
the scripts read them from the environment, these one-liners cannot.
|
||||
|
||||
```bash
|
||||
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
|
||||
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
|
||||
# file in backups/ that you will not discover until the day you need it.
|
||||
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_dump -U siftlode -Fc siftlode > "$out.part" \
|
||||
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|
||||
|| { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; }
|
||||
|
||||
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
|
||||
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
|
||||
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
|
||||
# is only partly back.
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
|
||||
# Start it again only once the restore reported no errors — and, if you are rolling a release back,
|
||||
# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the
|
||||
# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not
|
||||
# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and
|
||||
# comes back on the image you are rolling back FROM.
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
|
||||
container and copy it out instead. Check the exit code between the two steps and use a timestamped
|
||||
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
|
||||
copy out the previous run's file and quietly overwrite your only backup with a stale one.
|
||||
|
||||
Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the
|
||||
copy would still run after a failed dump — and copy out the previous run's file:
|
||||
|
||||
```powershell
|
||||
& {
|
||||
# Inside the block, because a scriptblock inherits the caller's preference — under a profile that
|
||||
# sets SilentlyContinue, every `throw` below would be swallowed and the block would run on.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$compose = "docker-compose.selfhost.yml"
|
||||
New-Item -ItemType Directory -Force -Path backups | Out-Null
|
||||
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
|
||||
docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
|
||||
$code = $LASTEXITCODE
|
||||
# Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run
|
||||
# copies out this run's file and overwrites a good backup with a stale one.
|
||||
if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" }
|
||||
docker compose -f $compose cp db:/tmp/s.dump $out
|
||||
$code = $LASTEXITCODE
|
||||
docker compose -f $compose exec -T db rm -f /tmp/s.dump
|
||||
# Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump
|
||||
# under the final name — the plausible-looking bad backup the .part dance above avoids.
|
||||
if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" }
|
||||
if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" }
|
||||
"wrote $out"
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
**Take a backup first.** Every start runs any new database migrations automatically, and a migration
|
||||
is not reversible by pulling the old image back — see rollback below.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move,
|
||||
pin a version** by putting it in `.env`:
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=0.80.0
|
||||
```
|
||||
|
||||
Then `pull` + `up -d` stays on that version until you change the line. Released versions and what
|
||||
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md).
|
||||
|
||||
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
|
||||
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
|
||||
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
|
||||
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
|
||||
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
|
||||
repository. If you want to sit on one version long term, keep your own copy:
|
||||
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
|
||||
|
||||
### Rollback
|
||||
|
||||
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
|
||||
**as long as the release did not change the database schema**. If it did, the older image will refuse
|
||||
to start against the newer schema.
|
||||
|
||||
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
|
||||
exactly where the old image expects it, in one step, with no revision arithmetic:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped:
|
||||
# starting it here would run the NEWER image's migrations against the database you just restored.
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
# 3. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
|
||||
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
|
||||
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
|
||||
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
|
||||
which that version can tell you itself:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. ask the version you are rolling back TO which schema revision it expects
|
||||
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
|
||||
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
|
||||
# because it is the one that ships the migrations being undone
|
||||
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
|
||||
# 4. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
A downgrade drops the columns the newer version added, so anything stored only in those columns is
|
||||
gone. That is the price of not having the dump.
|
||||
|
||||
### A migration failed on start
|
||||
|
||||
The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an
|
||||
`api` that never becomes healthy. Read the reason first:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs api | tail -50
|
||||
```
|
||||
|
||||
Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the
|
||||
previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless
|
||||
you have a dump you have actually tested restoring — that volume *is* your instance.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "siftlode-frontend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+14
-3
@@ -17,6 +17,7 @@ import {
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
@@ -54,6 +56,11 @@ export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, enterYtSearch } = useNavigationActions();
|
||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
||||
const verifyEntry = useVerifyEntry();
|
||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
@@ -240,7 +247,10 @@ export default function App() {
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
const pending = meQuery.data.pending_invites ?? 0;
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
@@ -259,7 +269,7 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
@@ -288,7 +298,7 @@ export default function App() {
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
if (!meQuery.data) return <Welcome />;
|
||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
||||
const pageFallback = (
|
||||
@@ -386,6 +396,7 @@ export default function App() {
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
|
||||
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
|
||||
// one having read what is actually wrong.
|
||||
|
||||
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
|
||||
function AccessRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "muted" | "warn" | "ok";
|
||||
}) {
|
||||
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
||||
<span className="text-muted w-28 shrink-0">{label}</span>
|
||||
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
if (!link) return;
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
||||
// action failing. Say so rather than leaving the button looking inert.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.display_name ?? user.email.split("@")[0]}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !a ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
value={
|
||||
a.email_verified
|
||||
? t("users.account.emailConfirmed")
|
||||
: t("users.account.emailUnconfirmed")
|
||||
}
|
||||
tone={a.email_verified ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Clock}
|
||||
label={t("users.account.approval")}
|
||||
value={
|
||||
a.is_active
|
||||
? t("users.account.approvalDone")
|
||||
: a.can_activate
|
||||
? t("users.account.approvalWaitingOnYou")
|
||||
: t("users.account.approvalNotRequested")
|
||||
}
|
||||
tone={a.is_active ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={KeyRound}
|
||||
label={t("users.account.password")}
|
||||
value={
|
||||
a.has_password
|
||||
? t("users.account.passwordSet")
|
||||
: a.password_revoked_at
|
||||
? t("users.account.passwordRevoked")
|
||||
: t("users.account.passwordNone")
|
||||
}
|
||||
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
||||
// account never had one and is working exactly as intended.
|
||||
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Globe}
|
||||
label={t("users.account.google")}
|
||||
value={
|
||||
!a.has_google
|
||||
? t("users.account.googleNone")
|
||||
: a.google_email
|
||||
? t("users.account.googleAs", { email: a.google_email })
|
||||
: t("users.account.googleConnected")
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
{!a.is_allowed && (
|
||||
<AccessRow
|
||||
icon={ShieldAlert}
|
||||
label={t("users.account.invite")}
|
||||
value={t("users.account.inviteMissing")}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<button
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
{!a.email_configured && (
|
||||
<span className="text-muted">{t("users.account.mailOff")}</span>
|
||||
)}
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,37 @@ import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||
import { api, HttpError, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Clock,
|
||||
MailWarning,
|
||||
Mails,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
type AdminUserRow,
|
||||
type Invite,
|
||||
type Me,
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -27,7 +50,14 @@ export default function AdminUsers() {
|
||||
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
||||
const tabs = [
|
||||
{ id: "roles", label: t("users.tabs.roles") },
|
||||
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
|
||||
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
|
||||
// address was approved BEFORE they registered — the second never reaches the invite queue,
|
||||
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
|
||||
{
|
||||
id: "access",
|
||||
label: t("users.tabs.access"),
|
||||
badge: me.pending_invites + me.pending_activations,
|
||||
},
|
||||
{ id: "demo", label: t("users.tabs.demo") },
|
||||
];
|
||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||
@@ -41,7 +71,12 @@ export default function AdminUsers() {
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
{active === "roles" && <UsersRoles me={me} />}
|
||||
{active === "access" && <AdminInvites />}
|
||||
{active === "access" && (
|
||||
<>
|
||||
<AdminInvites />
|
||||
<PendingAccess />
|
||||
</>
|
||||
)}
|
||||
{active === "demo" && <AdminDemo />}
|
||||
</div>
|
||||
</>
|
||||
@@ -74,6 +109,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
||||
// only one is ever mounted.
|
||||
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
const endPending = (id: number) =>
|
||||
setPendingIds((s) => {
|
||||
@@ -159,6 +197,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
|
||||
return (
|
||||
<Section card title={t("users.roles.title")}>
|
||||
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -174,13 +213,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
@@ -261,10 +304,195 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the invitation fail because this instance has no working SMTP? */
|
||||
function inviteEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "invite_send" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||
//
|
||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
|
||||
// empty while they sat locked out. The only working move was re-typing the address into the
|
||||
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
|
||||
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
|
||||
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
|
||||
// invite) and were reverted after review.
|
||||
function PendingAccess() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rows = useQuery({
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
||||
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
||||
const refresh = () => invalidateUserAccess(qc);
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||
},
|
||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const list = rows.data ?? [];
|
||||
const busy = activate.isPending || resend.isPending;
|
||||
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
|
||||
|
||||
return (
|
||||
<Section card title={t("users.pendingAccess.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
|
||||
{rows.isError && !rows.data ? (
|
||||
<StateMessage tone="error" onRetry={() => rows.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : rows.isLoading && !rows.data ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((u) => (
|
||||
<PendingAccessRowView
|
||||
key={u.id}
|
||||
row={u}
|
||||
busy={busy}
|
||||
onActivate={() => activate.mutate(u)}
|
||||
onResend={() => resend.mutate(u)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
// the difference between the owner and whoever registered the address first (roadmap SEC-15).
|
||||
function PendingAccessRowView({
|
||||
row,
|
||||
busy,
|
||||
onActivate,
|
||||
onResend,
|
||||
}: {
|
||||
row: PendingAccessRow;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{row.email}</div>
|
||||
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{row.created_at && (
|
||||
<span>
|
||||
{t("users.pendingAccess.signedUp", {
|
||||
time: new Date(row.created_at).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{row.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailConfirmed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warn">
|
||||
<MailWarning className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailUnconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
{!row.is_active && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t("users.pendingAccess.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The mail language for one invitation. Owns its own display state and reports every change up
|
||||
* through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has
|
||||
* to live in the caller's closure, not in this component. Endonyms come from the app's own
|
||||
* language list, so a third locale would appear here for free. */
|
||||
function InviteLangPicker({
|
||||
initial,
|
||||
onPick,
|
||||
}: {
|
||||
initial: LangCode;
|
||||
onPick: (lang: LangCode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [lang, setLang] = useState<LangCode>(initial);
|
||||
return (
|
||||
<SegmentedToggle
|
||||
className="mt-3"
|
||||
size="md"
|
||||
ariaLabel={t("settings.invites.inviteLang")}
|
||||
label={t("settings.invites.inviteLang")}
|
||||
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
value={lang}
|
||||
onChange={(v) => {
|
||||
setLang(v);
|
||||
onPick(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
|
||||
|
||||
function AdminInvites() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
@@ -273,6 +501,10 @@ function AdminInvites() {
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
|
||||
// The stuck-sign-ups section below describes the same backlog and changes with every decision
|
||||
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
|
||||
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
};
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
||||
@@ -338,6 +570,53 @@ function AdminInvites() {
|
||||
});
|
||||
if (ok) remove.mutate(i.id);
|
||||
};
|
||||
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
|
||||
// as a possibility (often already agreed in person), and a mail fired automatically would be
|
||||
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
|
||||
// decision rather than an accident.
|
||||
const invite = useMutation({
|
||||
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
|
||||
onSuccess: (_r, v) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||
},
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && inviteEmailOff(err)
|
||||
? t("settings.invites.inviteEmailOff")
|
||||
: t("settings.invites.inviteFailed"),
|
||||
}),
|
||||
});
|
||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
||||
// do it a second time, rather than only in a tooltip the admin may never hover.
|
||||
const onInvite = async (inv: Invite) => {
|
||||
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
||||
? (inv.lang as LangCode)
|
||||
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
||||
const ok = await confirm({
|
||||
title: t("settings.invites.inviteTitle"),
|
||||
message: (
|
||||
<>
|
||||
<p>
|
||||
{t(
|
||||
inv.invited_at
|
||||
? "settings.invites.inviteConfirmAgain"
|
||||
: "settings.invites.inviteConfirm",
|
||||
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
||||
</>
|
||||
),
|
||||
confirmLabel: t("settings.invites.inviteSend"),
|
||||
});
|
||||
if (ok) invite.mutate({ inv, lang });
|
||||
};
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (email: string) => api.addInvite(email),
|
||||
onSuccess: (_d, email) => {
|
||||
@@ -414,6 +693,35 @@ function AdminInvites() {
|
||||
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
||||
{i.status}
|
||||
</span>
|
||||
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
|
||||
open, come in", which is meaningless to someone already inside — and half the
|
||||
whitelist is existing users. An account that exists but is still stuck has its
|
||||
own surface above, with a re-send. The label carries the answer to "did I already
|
||||
write to them?", the question whose wrong answer means mailing a person twice. */}
|
||||
{i.status === "approved" && !i.has_account && (
|
||||
<Tooltip
|
||||
hint={
|
||||
i.invited_at
|
||||
? t("settings.invites.invitedAt", {
|
||||
time: new Date(i.invited_at).toLocaleString(),
|
||||
})
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
i.invited_at
|
||||
? "text-muted/50 hover:text-fg"
|
||||
: "text-muted hover:text-accent"
|
||||
}`}
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
user's access, since is_allowed reads this row. Delete the user instead. */}
|
||||
{i.has_account ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function Banner({
|
||||
action,
|
||||
onDismiss,
|
||||
dismissTitle,
|
||||
role,
|
||||
}: {
|
||||
tone: BannerTone;
|
||||
icon: LucideIcon;
|
||||
@@ -33,10 +34,17 @@ export default function Banner({
|
||||
action?: { label: string; onClick: () => void };
|
||||
onDismiss?: () => void;
|
||||
dismissTitle?: string;
|
||||
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
|
||||
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
|
||||
* page when it loads, and announcing them on every visit is noise, not information. */
|
||||
role?: "status";
|
||||
}) {
|
||||
const c = TONES[tone];
|
||||
return (
|
||||
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
|
||||
<div
|
||||
role={role}
|
||||
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||
<span className="min-w-0">{children}</span>
|
||||
{action && (
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
|
||||
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||
|
||||
@@ -25,11 +25,17 @@ export default function Modal({
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
placement = "center",
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
|
||||
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
|
||||
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
|
||||
* two shells drift apart. */
|
||||
placement?: "center" | "drawer";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
@@ -106,9 +112,12 @@ export default function Modal({
|
||||
};
|
||||
}, [restoreTo]);
|
||||
|
||||
const drawer = placement === "drawer";
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 overlay-backdrop"
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
}}
|
||||
@@ -122,7 +131,11 @@ export default function Modal({
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
|
||||
className={
|
||||
drawer
|
||||
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
||||
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
|
||||
@@ -281,6 +281,20 @@ export default function NavSidebar({
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* The address Google answers with, when it isn't the one above. /api/me sends it
|
||||
only when the two differ, so for most accounts this line never exists — and for
|
||||
someone who registered as `me+siftlode@gmail.com` it answers the question their
|
||||
sign-in screen would otherwise leave open: which address gets me in? A text
|
||||
label rather than a brand glyph, since the app draws one icon set (lucide) and a
|
||||
generic mail icon here would say nothing. */}
|
||||
{me.google_email && (
|
||||
<div
|
||||
className="text-2xs text-muted truncate mt-0.5"
|
||||
title={t("header.account.googleAddress", { email: me.google_email })}
|
||||
>
|
||||
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
|
||||
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
@@ -259,6 +260,13 @@ export default function Playlists() {
|
||||
return (v: Video) => m.get(v.id) ?? 0;
|
||||
}, [items]);
|
||||
|
||||
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
|
||||
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
|
||||
const totalSeconds = useMemo(
|
||||
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
||||
[t, storedIndex, onPlay, onRemove, canEdit],
|
||||
@@ -591,176 +599,192 @@ export default function Playlists() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-3">
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-start mb-4">
|
||||
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||||
row it sheds is list height won back — the old layout stacked three rows next to the
|
||||
cover plus a fourth block under it, for roughly twice this height. */}
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
question a user actually has — "did I just change my playlist?". Worse,
|
||||
sorting a list whose stored order ALREADY matches the sort produced a
|
||||
warning, no Save button and no visible change, which reads as a malfunction.
|
||||
Now the line says which of the three situations you are in; the disabled grip
|
||||
still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
|
||||
link + save hint + "reordering is off"), and none of them answered the question a
|
||||
user actually has — "did I just change my playlist?". Worse, sorting a list whose
|
||||
stored order ALREADY matches the sort produced a warning, no Save button and no
|
||||
visible change, which reads as a malfunction. Now the line says which of the three
|
||||
situations you are in; the disabled grip still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatDuration(pl.total_duration_seconds)}`}
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
|
||||
}, [q.data]);
|
||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
|
||||
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
|
||||
const totalSeconds = useMemo(
|
||||
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||
onBlur={rename}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||
onBlur={rename}
|
||||
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-0.5 text-sm text-muted">
|
||||
{t("plex.browse.items", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
|
||||
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
? t("settings.account.googleLink.connectedHint")
|
||||
: t("settings.account.googleLink.connectHint")}
|
||||
</p>
|
||||
{/* Which address Google actually answers with — sent only when it differs from the
|
||||
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
|
||||
exactly what someone who registered with a `+tag` needs to know. */}
|
||||
{me.google_email && (
|
||||
<p className="text-xs text-fg/80 mt-1.5">
|
||||
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
|
||||
{me.google_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{me.has_google ? (
|
||||
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -51,6 +53,8 @@ export default function Stats() {
|
||||
function Overview({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
|
||||
const { openWizard } = useWizard();
|
||||
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
|
||||
const s = status.data;
|
||||
@@ -64,7 +68,12 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
|
||||
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
|
||||
// "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare
|
||||
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
|
||||
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
|
||||
// rather than "Sync failed".
|
||||
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
|
||||
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
|
||||
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
|
||||
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
|
||||
// bar is only for the signed-in half.
|
||||
//
|
||||
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
|
||||
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
|
||||
// wrong-account case is why the address is named.
|
||||
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// A reset link opened here can only be ACTED on signed out, so say that rather than nothing —
|
||||
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
|
||||
// signing out from this same tab picks it up.
|
||||
//
|
||||
// Tested on `resetPending` ALONE, not `!outcome && resetPending`. The two can both be set (a
|
||||
// reset link pasted over an unread confirmation, or a slow verify resolving after one), and this
|
||||
// one goes first because it is the banner with something to DO. `dismiss` then peels it and the
|
||||
// outcome shows underneath — where `!outcome &&` would have hidden this one until the other was
|
||||
// dismissed, and dismissing that used to clear both.
|
||||
if (entry.resetPending) {
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{t("header.verify.resetSignedOut")}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!entry.outcome) return null;
|
||||
|
||||
const ok = entry.outcome === "ok" || entry.outcome === "ok_reset";
|
||||
const text =
|
||||
entry.outcome === "invalid"
|
||||
? t("header.verify.invalid")
|
||||
: entry.outcome === "unreachable"
|
||||
? t("header.verify.unreachable")
|
||||
: entry.email
|
||||
? t("header.verify.confirmedNamed", { email: entry.email })
|
||||
: t("header.verify.confirmed");
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone={ok ? "accent" : "warning"}
|
||||
icon={ok ? CheckCircle2 : AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{text}
|
||||
{entry.outcome === "ok_reset" && (
|
||||
<span className="text-muted"> {t("header.verify.reset")}</span>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,34 @@ import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
Code2,
|
||||
Download,
|
||||
Expand,
|
||||
Filter,
|
||||
Inbox,
|
||||
Link2Off,
|
||||
ListVideo,
|
||||
Lock,
|
||||
MailWarning,
|
||||
PlayCircle,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Siftlode is open source; the landing footer links to the public repository.
|
||||
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
|
||||
import { api } from "../lib/api";
|
||||
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
|
||||
import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
@@ -63,7 +70,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
|
||||
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
|
||||
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
|
||||
// backend); errors are surfaced inline (the api calls use the quiet flag).
|
||||
export default function Welcome() {
|
||||
export default function Welcome({ verify }: { verify: VerifyEntry }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Which screenshot (if any) is open in the full-size lightbox.
|
||||
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
|
||||
@@ -87,7 +94,7 @@ export default function Welcome() {
|
||||
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
|
||||
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
|
||||
</div>
|
||||
<AuthCard />
|
||||
<AuthCard verify={verify} />
|
||||
</section>
|
||||
|
||||
{/* App preview */}
|
||||
@@ -301,50 +308,54 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCard() {
|
||||
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
|
||||
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
|
||||
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
|
||||
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
|
||||
// in-flight promise means a remount joins the request already running instead of starting another.
|
||||
const resetTokenChecks = new Map<string, Promise<boolean>>();
|
||||
|
||||
function checkResetToken(token: string): Promise<boolean> {
|
||||
let pending = resetTokenChecks.get(token);
|
||||
if (!pending) {
|
||||
pending = api
|
||||
.checkPasswordReset(token)
|
||||
.then((r) => r.ok)
|
||||
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
|
||||
// itself: a network blip must never be cached as "this link is dead".
|
||||
.catch(() => {
|
||||
resetTokenChecks.delete(token);
|
||||
return true;
|
||||
});
|
||||
resetTokenChecks.set(token, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
|
||||
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
|
||||
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
|
||||
// stay in the query string.
|
||||
const [params] = useState(() => new URLSearchParams(window.location.search));
|
||||
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
|
||||
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
|
||||
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
|
||||
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
|
||||
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
|
||||
//
|
||||
// State, not a const: once the token has been spent the reset view must stop being the card's
|
||||
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => hashParams.get("reset"));
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
|
||||
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
|
||||
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
|
||||
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
|
||||
// explaining; the same spelling twice is just a repeat.
|
||||
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
|
||||
const bouncedEmail = entryHash.get("email");
|
||||
const bouncedLinked = entryHash.get("linked");
|
||||
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
|
||||
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
||||
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
||||
const [login, setLogin] = useState<string | null>(() => params.get("login"));
|
||||
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||
|
||||
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
|
||||
// doesn't linger in history.
|
||||
useEffect(() => {
|
||||
if (window.location.search || window.location.hash) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
|
||||
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
|
||||
// and wrongly flip the banner to "invalid".
|
||||
const verifyFired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!verifyToken || verifyFired.current) return;
|
||||
verifyFired.current = true;
|
||||
api
|
||||
.verifyEmail(verifyToken)
|
||||
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with (an
|
||||
// admin approved it before it was verified). Sign-in would answer with the uniform credentials
|
||||
// error, so point the user at the reset flow instead. Same value the legacy ?verify= redirect uses.
|
||||
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
|
||||
.catch(() => setVerify("invalid"));
|
||||
}, [verifyToken]);
|
||||
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
|
||||
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
|
||||
// one emailed link behaves the same whether or not this browser already holds a session.
|
||||
const verifyOutcome = verify.outcome;
|
||||
|
||||
const { view: mode, open: setMode } = useHistorySubview<Mode>(
|
||||
resetToken ? "reset" : "signin",
|
||||
@@ -365,12 +376,43 @@ function AuthCard() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
||||
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
|
||||
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
|
||||
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
|
||||
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
|
||||
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
|
||||
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
|
||||
const [deadReset, setDeadReset] = useState(false);
|
||||
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
|
||||
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
|
||||
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
|
||||
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
|
||||
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
|
||||
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
|
||||
useEffect(() => {
|
||||
const token = entryHash.get("reset");
|
||||
if (!token) return;
|
||||
let live = true;
|
||||
void checkResetToken(token).then((ok) => {
|
||||
if (!live) return;
|
||||
if (!ok) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
|
||||
}
|
||||
setCheckingReset(false);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
||||
// — without this, Back out of a submitted form would show the old success message under the
|
||||
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
||||
useEffect(() => {
|
||||
setDone("");
|
||||
setError("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
}, [mode]);
|
||||
|
||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||
@@ -383,9 +425,11 @@ function AuthCard() {
|
||||
const reset = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
||||
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
||||
setVerify(null);
|
||||
verify.dismiss();
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
@@ -439,6 +483,20 @@ function AuthCard() {
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
}
|
||||
} catch (err) {
|
||||
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
|
||||
// under the form, because there the form IS the next step.
|
||||
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
|
||||
if (refusal && PANEL_REASONS.has(refusal.reason)) {
|
||||
setBlocked(refusal);
|
||||
return;
|
||||
}
|
||||
// The token died between the pre-flight check and this submit — or was never checked, because
|
||||
// the probe itself failed. Either way the answer is the same screen, not a red line.
|
||||
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
||||
);
|
||||
@@ -467,15 +525,29 @@ function AuthCard() {
|
||||
}
|
||||
}
|
||||
|
||||
const titleKey = TITLE_KEY[mode];
|
||||
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
|
||||
// than a box sitting inside a heading that still says "Set a new password".
|
||||
const titleKey = blocked
|
||||
? `welcome.auth.blocked.${blocked.reason}.title`
|
||||
: deadReset
|
||||
? "welcome.auth.deadLink.title"
|
||||
: TITLE_KEY[mode];
|
||||
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
||||
{StateIcon && (
|
||||
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
||||
<StateIcon className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
)}
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "ok_reset" && (
|
||||
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verifyOutcome === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
@@ -483,7 +555,13 @@ function AuthCard() {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{verify === "invalid" && (
|
||||
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
||||
could not reach the server — so pushing the user to request a fresh link would act on a
|
||||
statement we know to be false (DX-16). Retrying the same link is the correct next move. */}
|
||||
{verifyOutcome === "unreachable" && (
|
||||
<Banner tone="warn">{t("welcome.auth.verifyUnreachable")}</Banner>
|
||||
)}
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
@@ -491,7 +569,26 @@ function AuthCard() {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
|
||||
{bounced === "requested" &&
|
||||
(bouncedEmail ? (
|
||||
<Banner tone="ok">
|
||||
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
|
||||
<Address value={bouncedEmail} />
|
||||
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
|
||||
</Banner>
|
||||
) : (
|
||||
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
|
||||
))}
|
||||
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
|
||||
mailbox whose request is already on file under another. Silence here is what let the
|
||||
2026-09-03 registrant believe nothing had been recorded at all. */}
|
||||
{bounced === "requested" && bouncedLinked && (
|
||||
<Banner tone="warn">
|
||||
<span className="block">{t("welcome.auth.accessLinked")}</span>
|
||||
<Address value={bouncedLinked} />
|
||||
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
||||
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
|
||||
{login === "oauth_cancelled" && (
|
||||
@@ -499,7 +596,22 @@ function AuthCard() {
|
||||
)}
|
||||
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
||||
|
||||
{done ? (
|
||||
{blocked ? (
|
||||
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
|
||||
) : deadReset ? (
|
||||
<StatePanel
|
||||
body={t("welcome.auth.deadLink.body")}
|
||||
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
|
||||
onBack={() => {
|
||||
reset();
|
||||
setMode("signin");
|
||||
}}
|
||||
/>
|
||||
) : checkingReset ? (
|
||||
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
|
||||
// whole check exists to remove.
|
||||
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
|
||||
) : done ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
|
||||
<button
|
||||
@@ -551,6 +663,18 @@ function AuthCard() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
|
||||
repeat sign-up. Offered right under the form rather than below the mode switches: it is
|
||||
the second thing a stuck person needs, after the password they just tried. */}
|
||||
{mode === "signin" && (
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mode switches */}
|
||||
<div className="flex items-center justify-between mt-3 text-xs">
|
||||
{mode === "signin" ? (
|
||||
@@ -589,14 +713,6 @@ function AuthCard() {
|
||||
|
||||
{mode === "signin" && (
|
||||
<>
|
||||
{/* A verification email can be lost or never arrive, so offer a resend path from
|
||||
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("welcome.auth.or")}
|
||||
@@ -647,9 +763,155 @@ function AuthCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// An email address the user has to READ and compare, character by character — the recorded address on
|
||||
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
|
||||
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
|
||||
// noticed on 2026-09-03.
|
||||
function Address({ value }: { value: string }) {
|
||||
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
|
||||
}
|
||||
|
||||
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
|
||||
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
|
||||
// line under it is the honest size for them.
|
||||
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
|
||||
"email_unverified",
|
||||
"pending_approval",
|
||||
"suspended",
|
||||
]);
|
||||
|
||||
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
|
||||
email_unverified: MailWarning,
|
||||
pending_approval: Clock,
|
||||
suspended: ShieldAlert,
|
||||
};
|
||||
|
||||
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
|
||||
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
|
||||
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
|
||||
// should not drift apart in shape.
|
||||
function StatePanel({
|
||||
body,
|
||||
cta,
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
body: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
onBack: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
|
||||
// live region a screen reader reports nothing at all, and without the focus move the button they
|
||||
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
|
||||
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
|
||||
// half — it is the whole answer to a submit — so it announces assertively.
|
||||
const heading = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => heading.current?.focus(), []);
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
|
||||
<p
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={onBack} className="text-sm text-accent hover:underline">
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
{cta && (
|
||||
<button
|
||||
onClick={cta.onClick}
|
||||
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
|
||||
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
|
||||
// string it replaces was true and useless: it never revealed that the address the account carried was
|
||||
// a different spelling of the one the user had just typed.
|
||||
function BlockedPanel({
|
||||
blocked,
|
||||
onResend,
|
||||
onBack,
|
||||
}: {
|
||||
blocked: LoginBlocked;
|
||||
onResend: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { reason, email, emailVerified, approved, operator } = blocked;
|
||||
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
|
||||
const gated = reason !== "suspended";
|
||||
return (
|
||||
<StatePanel
|
||||
body={t(`welcome.auth.blocked.${reason}.body`)}
|
||||
cta={
|
||||
reason === "email_unverified"
|
||||
? { label: t("welcome.auth.resendCta"), onClick: onResend }
|
||||
: undefined
|
||||
}
|
||||
onBack={onBack}
|
||||
>
|
||||
{gated && (
|
||||
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
|
||||
<Gate done={emailVerified === true} k="email" />
|
||||
<Gate done={approved === true} k="approval" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
|
||||
<span className="text-2xs uppercase tracking-wide text-muted">
|
||||
{t("welcome.auth.blocked.addressLabel")}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<Address value={email} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operator && (
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
{t("welcome.auth.blocked.operator", { contact: operator })}
|
||||
</p>
|
||||
)}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
|
||||
// label plus a status word: Hungarian puts them together differently, and gluing translated
|
||||
// fragments is how a bilingual UI ends up reading like neither language.
|
||||
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = done ? Check : Clock;
|
||||
return (
|
||||
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
|
||||
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// `role="status"` because these appear in response to something that just happened (a verification
|
||||
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
|
||||
// without it a screen-reader user gets no announcement that anything changed.
|
||||
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
|
||||
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
|
||||
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin account panel's RECOVERY actions, and specifically that a refusal is visible.
|
||||
//
|
||||
// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that
|
||||
// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal
|
||||
// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads
|
||||
// silence as "the mail went out". These pin that each failure says something, and that the resend
|
||||
// distinguishes "this instance has no working mail" from a genuine send failure.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const access = {
|
||||
email_verified: false,
|
||||
email_configured: true,
|
||||
is_active: false,
|
||||
is_allowed: true,
|
||||
can_activate: true,
|
||||
has_password: false,
|
||||
google_email: null,
|
||||
invite_status: null,
|
||||
};
|
||||
|
||||
const apiMock = {
|
||||
adminUserAccess: vi.fn(async () => access),
|
||||
createSetPasswordLink: vi.fn(),
|
||||
resendUserVerification: vi.fn(),
|
||||
activateUser: vi.fn(),
|
||||
};
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const AccountPanel = (await import("./AccountPanel")).default;
|
||||
|
||||
const user = {
|
||||
id: 7,
|
||||
email: "stuck@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
};
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<AccountPanel user={user as any} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Click the recovery button whose label is `key`, once the readout has loaded. */
|
||||
async function clickAction(key: string) {
|
||||
mount();
|
||||
await screen.findByText("users.account.recovery");
|
||||
fireEvent.click(screen.getByText(key));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
Object.values(apiMock).forEach((m) => m.mockClear());
|
||||
apiMock.adminUserAccess.mockResolvedValue(access);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const errorMessages = () =>
|
||||
notifyMock.mock.calls
|
||||
.map(([a]) => a as { level?: string; message?: string })
|
||||
.filter((a) => a.level === "error")
|
||||
.map((a) => a.message);
|
||||
|
||||
describe("the account panel reports a refused recovery action", () => {
|
||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||
apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null));
|
||||
|
||||
await clickAction("users.account.setPassword");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
|
||||
// …and no link block was rendered, so the toast is the only thing that happened.
|
||||
expect(screen.queryByText("users.account.copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
|
||||
// The success toast must NOT also fire — that is the exact lie the backend guards against.
|
||||
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
|
||||
"users.account.resendSent",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused activation", async () => {
|
||||
apiMock.activateUser.mockRejectedValue(
|
||||
new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.activate");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,12 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
type ConfirmOpts = { title?: string; message?: string; confirmLabel?: string; danger?: boolean };
|
||||
type ConfirmOpts = {
|
||||
title?: string;
|
||||
message?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => confirmMock,
|
||||
@@ -37,7 +42,13 @@ vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = { pending_invites: 0, email_configured: true, id: 1, role: "admin" };
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
@@ -49,6 +60,7 @@ vi.mock("./Tabs", () => ({
|
||||
|
||||
const listMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const inviteMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
@@ -57,6 +69,10 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
...actual.api,
|
||||
adminInvites: () => listMock(),
|
||||
deleteInvite: (id: number) => deleteMock(id),
|
||||
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
|
||||
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
|
||||
// renders nothing and this file stays about the delete affordance.
|
||||
adminPendingAccess: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -74,6 +90,8 @@ const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
decided_by: null,
|
||||
has_account: false,
|
||||
still_allowed_by_env: false,
|
||||
invited_at: null,
|
||||
lang: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -91,6 +109,9 @@ beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
|
||||
listMock.mockReset().mockResolvedValue([invite()]);
|
||||
inviteMock
|
||||
.mockReset()
|
||||
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
|
||||
meMock.email_configured = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
@@ -214,3 +235,113 @@ describe("Access requests — the delete affordance", () => {
|
||||
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
|
||||
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
|
||||
// silent and telling them is its own act, on its own button (E9.5 S6).
|
||||
describe("inviting someone the admin whitelisted", () => {
|
||||
// The decided rows live behind a <details>; await the summary rather than the section title, or
|
||||
// the click races the invite query that populates it.
|
||||
const open = async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText(/settings.invites.decided/));
|
||||
};
|
||||
|
||||
it("offers a send button on an approved row", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks first, and writes to nobody when the admin backs out", async () => {
|
||||
confirmMock.mockResolvedValue(false);
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
|
||||
expect(inviteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the language this person asked us in", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("sends in the language picked in the dialog, not the default", async () => {
|
||||
// The dialog is the only place this decision can be made — and the pick has to survive back to
|
||||
// the caller, because the confirmation resolves to a plain boolean.
|
||||
let release: (ok: boolean) => void = () => {};
|
||||
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
fireEvent.click(dialog.getByText("Magyar"));
|
||||
release(true);
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("warns in the dialog when this person has already been written to", async () => {
|
||||
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
|
||||
// to the button that would mail a real person a second time.
|
||||
listMock.mockResolvedValue([
|
||||
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
|
||||
]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
||||
});
|
||||
|
||||
it("offers none on a denied row, which would contradict the decision", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers none once somebody is already behind that address", async () => {
|
||||
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
|
||||
// whitelist is existing users. An account that exists but is stuck has its own row above.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25). Approve an address
|
||||
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
|
||||
// screen was empty while they sat locked out and the only working move — re-typing the address into
|
||||
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
|
||||
// what it does NOT show: the section is a discovery surface, and a person the queue above already
|
||||
// covers must not appear twice.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
|
||||
vi.mock("./Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const pendingMock = vi.fn();
|
||||
const activateMock = vi.fn();
|
||||
const resendMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => Promise.resolve([]),
|
||||
adminPendingAccess: () => pendingMock(),
|
||||
activateUser: (id: number) => activateMock(id),
|
||||
resendUserVerification: (id: number) => resendMock(id),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
/** A row as /api/admin/pending-access serves it. */
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
email: "later@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
email_verified: true,
|
||||
is_demo: false,
|
||||
has_password: true,
|
||||
has_google: false,
|
||||
created_at: "2026-09-01T10:00:00Z",
|
||||
can_activate: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pendingMock.mockReset();
|
||||
activateMock.mockReset().mockResolvedValue({});
|
||||
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the stuck sign-ups section", () => {
|
||||
it("offers Activate for a registrant whose address was already approved", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
const button = await screen.findByText("users.pendingAccess.activate");
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
|
||||
// Activating an address with no approved invite makes an account that signs in with a password
|
||||
// but is turned away by Google — the whitelist box is the honest move for that.
|
||||
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.emailUnconfirmed");
|
||||
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
|
||||
});
|
||||
|
||||
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
// On a no-SMTP instance this is the difference between the owner and whoever registered the
|
||||
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
|
||||
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
|
||||
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-sends the confirmation link for an unconfirmed account", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("offers no resend once the address is confirmed", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.activate");
|
||||
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
const { HttpError } = await import("../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing at all when nobody is stuck", async () => {
|
||||
pendingMock.mockResolvedValue([]);
|
||||
mount();
|
||||
|
||||
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
|
||||
// GO, not merely for the request to have been made.
|
||||
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
|
||||
expect(pendingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root:
|
||||
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
|
||||
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
|
||||
// the funnel stayed silent about it.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
|
||||
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
|
||||
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
|
||||
window.history.replaceState(null, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
...apiOverrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const { default: Welcome } = await import("./Welcome");
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
return { HttpError, Welcome };
|
||||
}
|
||||
|
||||
/** Fill the sign-in form and submit it. */
|
||||
async function signIn(email = "someone@example.com") {
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
|
||||
target: { value: "correct horse battery" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
|
||||
});
|
||||
}
|
||||
|
||||
/** A password-login refusal carrying the server's structured detail. */
|
||||
function refusal(
|
||||
HttpError: typeof import("../lib/api").HttpError,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
|
||||
}
|
||||
|
||||
describe("a refused sign-in becomes a named state", () => {
|
||||
it("names the address the account is on, not the one that was typed", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "pending_approval",
|
||||
email: "np.temu@gmail.com",
|
||||
email_verified: true,
|
||||
approved: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("nptemu@gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
|
||||
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
|
||||
// The password was already proven correct, so the form is not the next step and must be gone —
|
||||
// leaving it up would only invite a retry that cannot succeed.
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "email_unverified",
|
||||
email: "approved.first@example.com",
|
||||
email_verified: false,
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("approved.first@example.com");
|
||||
|
||||
// E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this
|
||||
// user off to wait for an admin who has already decided.
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "suspended",
|
||||
email: "banned@example.com",
|
||||
operator: "ops@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("banned@example.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
|
||||
);
|
||||
|
||||
await signIn();
|
||||
|
||||
expect(screen.getByText("localized invalid")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
|
||||
// password" form purely because a `#reset=` fragment was present, and only told the user after they
|
||||
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
|
||||
// whoever got two mails clicks the older one.
|
||||
describe("an emailed reset link that is already finished", () => {
|
||||
it("refuses to draw a form that cannot succeed", async () => {
|
||||
await mountCard("/#reset=dead-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: false }),
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the form when the link is still good", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the form down if the link dies between the check and the submit", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=racing-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "brand new password" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "0123456789ab" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("probes a token once, however many times the card mounts", async () => {
|
||||
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
|
||||
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
|
||||
// StrictMode double-invokes effects and the shell remounts this card as the session probe
|
||||
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
|
||||
// and silently drop back to drawing the form unchecked.
|
||||
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.reject(new Error("network")),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the access-request notice from a denied Google sign-in", () => {
|
||||
it("names the address the request was recorded for", async () => {
|
||||
await mountCard("/#access=requested&email=nptemu%40gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
|
||||
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains the alias when the request is already on file under another spelling", async () => {
|
||||
await mountCard(
|
||||
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
|
||||
);
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
|
||||
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
|
||||
// address family this epic exists to protect.
|
||||
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the unnamed notice when the server sent no address", async () => {
|
||||
await mountCard("/#access=requested");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ export type PageDef = {
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
feed: { Component: FeedPage },
|
||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: MessagesPage },
|
||||
|
||||
@@ -28,6 +28,16 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Welcome from "./Welcome";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
// sub-views, so there is nothing to report.
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
const back = () =>
|
||||
act(() => {
|
||||
@@ -45,7 +55,7 @@ afterEach(() => {
|
||||
|
||||
describe("sign-in card sub-views and the Back button", () => {
|
||||
it("opens Forgot password as a history entry and comes back to sign in", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
const entries = window.history.length;
|
||||
|
||||
@@ -62,7 +72,7 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
});
|
||||
|
||||
it("does the same for Create an account and the verification-email link", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.createLink"));
|
||||
@@ -76,3 +86,51 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
|
||||
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
|
||||
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
|
||||
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
|
||||
// screen that said nothing at all.
|
||||
describe("the verification outcome handed down from App", () => {
|
||||
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
||||
outcome,
|
||||
email: "someone@example.com",
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
});
|
||||
|
||||
it("announces a confirmed address", async () => {
|
||||
render(<Welcome verify={withOutcome("ok")} />);
|
||||
const banner = await screen.findByText("welcome.auth.verifyOk");
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers the reset flow when the account's password had been revoked", async () => {
|
||||
render(<Welcome verify={withOutcome("ok_reset")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a new link when the token is spent", async () => {
|
||||
render(<Welcome verify={withOutcome("invalid")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
// DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link —
|
||||
// that would act on a statement we know to be false. Retrying the same link is the way out.
|
||||
it("does not offer a new link when the server was merely unreachable", async () => {
|
||||
render(<Welcome verify={withOutcome("unreachable")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.resendCta")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no banner when there was no token to confirm", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"user_suspend": "User suspended",
|
||||
"user_unsuspend": "User reinstated",
|
||||
"user_delete": "User deleted",
|
||||
"user_activate": "Account activated",
|
||||
"user_verify_resend": "Confirmation link re-sent",
|
||||
"user_setpw_link": "Set-password link issued",
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
@@ -20,5 +23,6 @@
|
||||
"audit_clear": "Audit log cleared",
|
||||
"download_credential_set": "Download cookies set",
|
||||
"download_credential_delete": "Download cookies removed",
|
||||
"invite_delete": "Whitelist entry deleted"
|
||||
"invite_delete": "Whitelist entry deleted",
|
||||
"invite_send": "Invitation sent"
|
||||
}
|
||||
|
||||
@@ -229,6 +229,14 @@
|
||||
"tooLarge": "That file is too large (max {{size}}).",
|
||||
"empty": "That file is empty."
|
||||
},
|
||||
"urlBlocked": {
|
||||
"scheme": "Only http:// and https:// links can be downloaded.",
|
||||
"no_host": "That link doesn't contain a website address.",
|
||||
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
|
||||
"private": "That address is on the server's own network, so it can't be downloaded from.",
|
||||
"generic": "That address can't be downloaded from.",
|
||||
"malformed": "That link isn't a valid web address."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
"detailsLink": "details",
|
||||
@@ -273,6 +281,10 @@
|
||||
"unknown": {
|
||||
"short": "Download failed",
|
||||
"body": "Something went wrong while downloading this item. The technical details below may help."
|
||||
},
|
||||
"quota_exceeded": {
|
||||
"short": "Storage full",
|
||||
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"generic": "The server couldn't carry out that action. Please try again.",
|
||||
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
|
||||
"server": "Server error",
|
||||
"syncCooldown": "You've synced a few times just now — give it a few minutes. The YouTube budget is shared with everyone on this instance, and background syncing keeps running either way.",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Connection lost",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"signOut": "Sign out",
|
||||
"addAccount": "Add another account",
|
||||
"switchTo": "Switch to {{name}}",
|
||||
"audit": "Audit log"
|
||||
"audit": "Audit log",
|
||||
"googleAddress": "Google account: {{email}}"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "yours",
|
||||
@@ -55,5 +56,13 @@
|
||||
"updated": "Updated to v{{version}} — see what's changed.",
|
||||
"releaseNotes": "Release notes",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"verify": {
|
||||
"confirmed": "Email address confirmed.",
|
||||
"confirmedNamed": "Email address confirmed — {{email}}.",
|
||||
"invalid": "That verification link is no longer valid — it has expired or was already used.",
|
||||
"unreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.",
|
||||
"resetSignedOut": "Password-reset links open in a signed-out window. Sign out here, or open the link in a private window, and it will pick up where it left off.",
|
||||
"reset": "The password this account registered with was cleared for security; set a new one before signing in."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,8 @@
|
||||
"browse": "Browse"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} items"
|
||||
"items_one": "{{count}} item",
|
||||
"items_other": "{{count}} items"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Discover",
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google account linked.",
|
||||
"conflict": "That Google account is already linked to a different Siftlode account.",
|
||||
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
|
||||
"error": "Couldn't link the Google account. Please try again."
|
||||
"error": "Couldn't link the Google account. Please try again.",
|
||||
"signedInAs": "Signed in with Google as"
|
||||
},
|
||||
"googleCancelled": "Google sign-in was cancelled.",
|
||||
"password": {
|
||||
@@ -156,6 +157,17 @@
|
||||
"approveFailed": "Approve failed",
|
||||
"denyFailed": "Deny failed",
|
||||
"addedToWhitelist": "{{email}} added to the whitelist",
|
||||
"inviteHint": "Send them an invitation. Adding an address never mails anyone — this does.",
|
||||
"invitedAt": "Invited {{time}}. Sending again will mail them a second time.",
|
||||
"inviteLabel": "Send an invitation to {{email}}",
|
||||
"invited": "Invitation sent to {{email}}.",
|
||||
"inviteFailed": "Couldn't send that invitation.",
|
||||
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
|
||||
"inviteTitle": "Send invitation?",
|
||||
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
|
||||
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
|
||||
"inviteSend": "Send",
|
||||
"inviteLang": "Mail language",
|
||||
"addFailed": "Couldn't add that email",
|
||||
"requested": "requested {{time}}",
|
||||
"approveHint": "Approve — whitelist this email and email them they're in.",
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
"access": "Access requests",
|
||||
"demo": "Demo"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Signed up, but still outside",
|
||||
"body": "These people created an account that can't sign in yet. If you approved their address before they registered, no request reached the queue above — let them in here.",
|
||||
"signedUp": "Signed up {{time}}",
|
||||
"emailConfirmed": "email confirmed",
|
||||
"emailUnconfirmed": "email not confirmed",
|
||||
"inactive": "waiting for activation",
|
||||
"activate": "Activate",
|
||||
"activateHint": "Let this account in. Their address is already approved — this only finishes it.",
|
||||
"activated": "{{email}} can sign in now.",
|
||||
"activateFailed": "Couldn't activate that account.",
|
||||
"resend": "Send the confirmation link again",
|
||||
"resendHint": "Send the confirmation link again, to the address on the account.",
|
||||
"resent": "Confirmation link sent to {{email}}.",
|
||||
"resendFailed": "Couldn't send the confirmation link.",
|
||||
"resendEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Users & roles",
|
||||
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
|
||||
@@ -42,5 +59,39 @@
|
||||
"title": "Delete account?",
|
||||
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
|
||||
"done": "{{email}} deleted"
|
||||
},
|
||||
"account": {
|
||||
"access": "Access",
|
||||
"activate": "Let this account in",
|
||||
"alreadyConfirmed": "already confirmed",
|
||||
"approval": "Admin approval",
|
||||
"approvalDone": "let in",
|
||||
"approvalNotRequested": "no approved invite yet",
|
||||
"approvalWaitingOnYou": "waiting on you",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"copyFailed": "Couldn't copy — select the link and copy it by hand.",
|
||||
"email": "Email",
|
||||
"emailConfirmed": "confirmed",
|
||||
"emailUnconfirmed": "not confirmed",
|
||||
"google": "Google",
|
||||
"googleAs": "connected as {{email}}",
|
||||
"googleConnected": "connected",
|
||||
"googleNone": "not connected",
|
||||
"invite": "Invite",
|
||||
"inviteMissing": "this address is not on the allow-list",
|
||||
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
|
||||
"mailOff": "no mail configured",
|
||||
"open": "Open the account panel",
|
||||
"password": "Password",
|
||||
"passwordNone": "none — signs in with Google",
|
||||
"passwordRevoked": "cleared for security",
|
||||
"passwordSet": "set",
|
||||
"recovery": "Recovery",
|
||||
"resend": "Send a confirmation link",
|
||||
"resendSent": "Confirmation link sent.",
|
||||
"setPassword": "Create a set-password link",
|
||||
"setPasswordAgain": "Create another link",
|
||||
"linkFailed": "Couldn't create the link. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +82,59 @@
|
||||
"demoEmail": "Demo email",
|
||||
"demoEnter": "Enter",
|
||||
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
|
||||
"registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.",
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
|
||||
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
|
||||
"resetDone": "Your password has been set — you can sign in now.",
|
||||
"deadLink": {
|
||||
"title": "This link is no longer valid",
|
||||
"body": "A password reset link lasts one hour and works once. This one has expired, has already been used, or belongs to an account that no longer exists — so there is nothing to set a password on. Ask for a fresh one and we'll email it.",
|
||||
"short": "This reset link is no longer valid.",
|
||||
"cta": "Request a new link"
|
||||
},
|
||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyOkReset": "Email confirmed. Your account was approved before you confirmed it, so the password you registered with was cleared for security — choose a new one to sign in.",
|
||||
"verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.",
|
||||
"verifyOkResetCta": "Set a new password",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"verifyUnreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.",
|
||||
"resendCta": "Send a new link",
|
||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||
"resendDone": "If that email has an unconfirmed account, we've sent a new confirmation link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
|
||||
"oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.",
|
||||
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
|
||||
"accessRequestedFor": "We've recorded your access request for:",
|
||||
"accessRequestedReview": "An admin will review it, and we'll email you either way.",
|
||||
"accessLinked": "You already had a request on file for this mailbox, under a different spelling:",
|
||||
"accessLinkedTail": "Google returns one fixed spelling of a Gmail address, so we linked this sign-in to that request instead of opening a second one. Any mail goes to the address above.",
|
||||
"blocked": {
|
||||
"addressLabel": "Address on file",
|
||||
"operator": "If you think this is a mistake, contact the operator at {{contact}}.",
|
||||
"gate": {
|
||||
"emailDone": "Email address confirmed",
|
||||
"emailPending": "Email address — not confirmed yet",
|
||||
"approvalDone": "Admin approval — granted",
|
||||
"approvalPending": "Admin approval — pending"
|
||||
},
|
||||
"email_unverified": {
|
||||
"title": "Confirm your email address",
|
||||
"body": "Your password is correct — we just can't let you in until the address on this account is confirmed. Open the link we emailed you, or ask for a new one.",
|
||||
"short": "Confirm your email address first — check your inbox."
|
||||
},
|
||||
"pending_approval": {
|
||||
"title": "Waiting for approval",
|
||||
"body": "Your password is correct and your address is confirmed. An admin still has to let you in — we'll email you as soon as they decide.",
|
||||
"short": "Your account is waiting for admin approval."
|
||||
},
|
||||
"suspended": {
|
||||
"title": "Account suspended",
|
||||
"body": "This account has been suspended, so it can't sign in by any method.",
|
||||
"short": "This account has been suspended."
|
||||
},
|
||||
"invalid": {
|
||||
"short": "Invalid email or password."
|
||||
},
|
||||
"rate_limited": {
|
||||
"short": "Too many attempts. Try again shortly."
|
||||
}
|
||||
},
|
||||
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
|
||||
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
|
||||
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"user_suspend": "Felhasználó felfüggesztve",
|
||||
"user_unsuspend": "Felhasználó visszaállítva",
|
||||
"user_delete": "Felhasználó törölve",
|
||||
"user_activate": "Fiók aktiválva",
|
||||
"user_verify_resend": "Megerősítő link újraküldve",
|
||||
"user_setpw_link": "Jelszóbeállító link kiadva",
|
||||
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||
"invite_add": "Email fehérlistázva",
|
||||
@@ -20,5 +23,6 @@
|
||||
"audit_clear": "Napló ürítve",
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k eltávolítva",
|
||||
"invite_delete": "Whitelista-bejegyzés törölve"
|
||||
"invite_delete": "Whitelista-bejegyzés törölve",
|
||||
"invite_send": "Meghívó elküldve"
|
||||
}
|
||||
|
||||
@@ -229,6 +229,14 @@
|
||||
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
|
||||
"empty": "Ez a fájl üres."
|
||||
},
|
||||
"urlBlocked": {
|
||||
"scheme": "Csak http:// és https:// hivatkozás tölthető le.",
|
||||
"no_host": "Ez a hivatkozás nem tartalmaz webcímet.",
|
||||
"unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.",
|
||||
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.",
|
||||
"generic": "Erről a címről nem lehet letölteni.",
|
||||
"malformed": "Ez a hivatkozás nem érvényes webcím."
|
||||
},
|
||||
"fail": {
|
||||
"title": "A letöltés nem sikerült",
|
||||
"detailsLink": "részletek",
|
||||
@@ -273,6 +281,10 @@
|
||||
"unknown": {
|
||||
"short": "A letöltés nem sikerült",
|
||||
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
||||
},
|
||||
"quota_exceeded": {
|
||||
"short": "Betelt a tárhely",
|
||||
"body": "Ez a letöltés leállt, mert a tárhelykereted már elfogyott. Törölj valamit a könyvtáradból, aztán próbáld újra."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
|
||||
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
|
||||
"server": "Szerverhiba",
|
||||
"syncCooldown": "Az imént többször is szinkronizáltál — várj pár percet. A YouTube-keret közös az instance összes felhasználójával, a háttérszinkron pedig így is fut tovább.",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Megszakadt a kapcsolat",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"signOut": "Kijelentkezés",
|
||||
"addAccount": "Másik fiók hozzáadása",
|
||||
"switchTo": "Váltás erre: {{name}}",
|
||||
"audit": "Naplózás"
|
||||
"audit": "Naplózás",
|
||||
"googleAddress": "Google-fiók: {{email}}"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "tiéd",
|
||||
@@ -55,5 +56,13 @@
|
||||
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
|
||||
"releaseNotes": "Verziójegyzék",
|
||||
"dismiss": "Elvetés"
|
||||
},
|
||||
"verify": {
|
||||
"confirmed": "Megerősítettük az e-mail-címedet.",
|
||||
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.",
|
||||
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.",
|
||||
"unreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.",
|
||||
"resetSignedOut": "A jelszó-visszaállító linkek kijelentkezett ablakban nyílnak meg. Jelentkezz ki itt, vagy nyisd meg a linket privát ablakban, és onnan folytatódik.",
|
||||
"reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,8 @@
|
||||
"browse": "Böngészés"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} elem"
|
||||
"items_one": "{{count}} elem",
|
||||
"items_other": "{{count}} elem"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Felfedezés",
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google-fiók összekapcsolva.",
|
||||
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
|
||||
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
|
||||
"signedInAs": "Google-belépés ezzel a címmel:"
|
||||
},
|
||||
"googleCancelled": "A Google-belépés megszakadt.",
|
||||
"password": {
|
||||
@@ -156,6 +157,17 @@
|
||||
"approveFailed": "A jóváhagyás sikertelen",
|
||||
"denyFailed": "Az elutasítás sikertelen",
|
||||
"addedToWhitelist": "{{email}} hozzáadva a fehérlistához",
|
||||
"inviteHint": "Küldj neki meghívót. A cím hozzáadása soha nem küld levelet — ez igen.",
|
||||
"invitedAt": "Meghívva: {{time}}. Az újraküldés másodszor is levelet küld neki.",
|
||||
"inviteLabel": "Meghívó küldése ide: {{email}}",
|
||||
"invited": "Meghívó elküldve ide: {{email}}.",
|
||||
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
|
||||
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
|
||||
"inviteTitle": "Küldesz meghívót?",
|
||||
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
|
||||
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
|
||||
"inviteSend": "Küldés",
|
||||
"inviteLang": "A levél nyelve",
|
||||
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
|
||||
"requested": "kérve: {{time}}",
|
||||
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
"access": "Hozzáférési kérések",
|
||||
"demo": "Demo"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Regisztrált, de még kívül van",
|
||||
"body": "Ezek a fiókok léteznek, de még nem tudnak belépni. Ha a címüket a regisztrációjuk előtt hagytad jóvá, a fenti sorba semmi nem került — itt engedheted be őket.",
|
||||
"signedUp": "Regisztrált: {{time}}",
|
||||
"emailConfirmed": "cím megerősítve",
|
||||
"emailUnconfirmed": "cím nincs megerősítve",
|
||||
"inactive": "aktiválásra vár",
|
||||
"activate": "Aktiválás",
|
||||
"activateHint": "Beengedi ezt a fiókot. A címe már jóvá van hagyva — ez csak befejezi.",
|
||||
"activated": "{{email}} mostantól be tud lépni.",
|
||||
"activateFailed": "Nem sikerült aktiválni a fiókot.",
|
||||
"resend": "Megerősítő link újraküldése",
|
||||
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
|
||||
"resent": "Megerősítő link elküldve ide: {{email}}.",
|
||||
"resendFailed": "Nem sikerült elküldeni a megerősítő linket.",
|
||||
"resendEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Felhasználók és szerepek",
|
||||
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
|
||||
@@ -42,5 +59,39 @@
|
||||
"title": "Törlöd a fiókot?",
|
||||
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
|
||||
"done": "{{email}} törölve"
|
||||
},
|
||||
"account": {
|
||||
"access": "Hozzáférés",
|
||||
"activate": "Fiók beengedése",
|
||||
"alreadyConfirmed": "már megerősítve",
|
||||
"approval": "Admin jóváhagyás",
|
||||
"approvalDone": "beengedve",
|
||||
"approvalNotRequested": "még nincs jóváhagyott meghívó",
|
||||
"approvalWaitingOnYou": "rád vár",
|
||||
"copied": "Másolva",
|
||||
"copy": "Másolás",
|
||||
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel.",
|
||||
"email": "E-mail",
|
||||
"emailConfirmed": "megerősítve",
|
||||
"emailUnconfirmed": "nincs megerősítve",
|
||||
"google": "Google",
|
||||
"googleAs": "összekötve mint {{email}}",
|
||||
"googleConnected": "összekötve",
|
||||
"googleNone": "nincs összekötve",
|
||||
"invite": "Meghívó",
|
||||
"inviteMissing": "ez a cím nincs az engedélyezési listán",
|
||||
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
|
||||
"mailOff": "nincs beállítva levelezés",
|
||||
"open": "Fiókpanel megnyitása",
|
||||
"password": "Jelszó",
|
||||
"passwordNone": "nincs — Google-lel lép be",
|
||||
"passwordRevoked": "biztonsági okból törölve",
|
||||
"passwordSet": "be van állítva",
|
||||
"recovery": "Helyreállítás",
|
||||
"resend": "Megerősítő link küldése",
|
||||
"resendSent": "Megerősítő link elküldve.",
|
||||
"setPassword": "Jelszóbeállító link készítése",
|
||||
"setPasswordAgain": "Új link készítése",
|
||||
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +82,59 @@
|
||||
"demoEmail": "Demó e-mail",
|
||||
"demoEnter": "Belépés",
|
||||
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
|
||||
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
|
||||
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
|
||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
|
||||
"deadLink": {
|
||||
"title": "Ez a link már nem érvényes",
|
||||
"body": "A jelszó-visszaállító link egy óráig él, és egyszer használható. Ez lejárt, már felhasználták, vagy olyan fiókhoz tartozik, ami már nem létezik — nincs mire jelszót állítani. Kérj újat, és elküldjük.",
|
||||
"short": "Ez a visszaállító link már nem érvényes.",
|
||||
"cta": "Új link kérése"
|
||||
},
|
||||
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
|
||||
"verifyOkReset": "E-mail megerősítve. A fiókodat még a megerősítés előtt jóváhagyták, ezért biztonsági okból töröltük a regisztrációkor megadott jelszavadat — válassz újat a belépéshez.",
|
||||
"verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.",
|
||||
"verifyOkResetCta": "Új jelszó beállítása",
|
||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||
"verifyUnreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.",
|
||||
"resendCta": "Új link küldése",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
|
||||
"oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.",
|
||||
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
|
||||
"accessRequestedFor": "Rögzítettük a hozzáférési kérelmedet erre a címre:",
|
||||
"accessRequestedReview": "Egy admin átnézi, és e-mailben szólunk a döntésről.",
|
||||
"accessLinked": "Ehhez a postafiókhoz már volt kérelmed, csak más írásmóddal:",
|
||||
"accessLinkedTail": "A Google egy rögzített írásmódot ad vissza a Gmail-címekből, ezért ezt a belépést a meglévő kérelemhez kötöttük, nem indítottunk újat. A levelek a fenti címre mennek.",
|
||||
"blocked": {
|
||||
"addressLabel": "Rögzített cím",
|
||||
"operator": "Ha tévedésnek gondolod, keresd az üzemeltetőt: {{contact}}.",
|
||||
"gate": {
|
||||
"emailDone": "E-mail-cím megerősítve",
|
||||
"emailPending": "E-mail-cím — még nincs megerősítve",
|
||||
"approvalDone": "Admin jóváhagyás — megvan",
|
||||
"approvalPending": "Admin jóváhagyás — folyamatban"
|
||||
},
|
||||
"email_unverified": {
|
||||
"title": "Erősítsd meg az e-mail-címed",
|
||||
"body": "A jelszavad jó — csak addig nem engedhetünk be, amíg a fiókon lévő cím nincs megerősítve. Nyisd meg a kiküldött linket, vagy kérj újat.",
|
||||
"short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod."
|
||||
},
|
||||
"pending_approval": {
|
||||
"title": "Jóváhagyásra vár",
|
||||
"body": "A jelszavad jó, a címed megerősítve. Már csak arra várunk, hogy egy admin beengedjen — szólunk e-mailben, amint döntött.",
|
||||
"short": "A fiókod admin jóváhagyásra vár."
|
||||
},
|
||||
"suspended": {
|
||||
"title": "Felfüggesztett fiók",
|
||||
"body": "Ez a fiók fel van függesztve, így semmilyen módon nem tud belépni.",
|
||||
"short": "Ez a fiók fel van függesztve."
|
||||
},
|
||||
"invalid": {
|
||||
"short": "Hibás e-mail-cím vagy jelszó."
|
||||
},
|
||||
"rate_limited": {
|
||||
"short": "Túl sok próbálkozás. Próbáld újra kicsit később."
|
||||
}
|
||||
},
|
||||
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
|
||||
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
|
||||
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
|
||||
|
||||
@@ -657,6 +657,24 @@ html[data-perf="1"] .overlay-backdrop {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that
|
||||
came from the side rather than a dialog that happens to be right-aligned — so it is part of the
|
||||
affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */
|
||||
.overlay-drawer {
|
||||
animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
@keyframes drawer-in {
|
||||
from {
|
||||
transform: translateX(1.5rem);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay-drawer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while
|
||||
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
|
||||
stays stable; `auto` lets the browser remember each card's real size after it
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom because the shared module reaches lib/api, which pulls in i18n's localStorage probe.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { invalidateUserAccess, isEmailOff } from "./adminUsers";
|
||||
import { HttpError } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// The set matters, not the call: letting someone in changes three things a user can see at once —
|
||||
// the row, the pending queue, and the /api/me badge — and the account panel shipped invalidating
|
||||
// only the first two, so the badge went on claiming the person was stuck until a reload. Pinning the
|
||||
// keys here is what stops the two call sites drifting apart again, since neither component test
|
||||
// asserts on invalidation.
|
||||
describe("invalidateUserAccess", () => {
|
||||
it("refreshes the row, the pending queue and the badge", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve());
|
||||
|
||||
invalidateUserAccess(qc);
|
||||
|
||||
const keys = spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey));
|
||||
expect(keys).toEqual(
|
||||
[qk.adminUsers(), qk.adminPendingAccess(), qk.me()].map((k) => JSON.stringify(k)),
|
||||
);
|
||||
});
|
||||
|
||||
it("also refreshes one account's gate readout when given an id", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve());
|
||||
|
||||
invalidateUserAccess(qc, 7);
|
||||
|
||||
expect(spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey))).toContain(
|
||||
JSON.stringify(qk.adminUserAccess(7)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The admin-side resend deliberately reports failures (the public one is silent), so it has to be
|
||||
// able to say WHICH — a no-SMTP instance is a configuration answer, not a bug.
|
||||
describe("isEmailOff", () => {
|
||||
it("recognises the no-mail refusal", () => {
|
||||
expect(isEmailOff(new HttpError(409, "x", { code: "resend", reason: "email_off" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for any other failure, including a non-HttpError", () => {
|
||||
expect(isEmailOff(new HttpError(422, "x", { code: "resend", reason: "send_failed" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isEmailOff(new HttpError(500, "x", null))).toBe(false);
|
||||
expect(isEmailOff(new TypeError("Failed to fetch"))).toBe(false);
|
||||
expect(isEmailOff(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpError } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// Shared bits of the admin's user-management surface, extracted because the SAME two mutations
|
||||
// (activate, resend-verification) are now offered from two places — the pending-access queue and
|
||||
// the account panel — and the second copy quietly dropped half of what the first one does.
|
||||
//
|
||||
// Grouped invalidation lives per domain rather than in `qk` (see the contract note there);
|
||||
// `invalidateDownloads` is the same pattern for the downloads domain.
|
||||
|
||||
/** Everything that goes stale when an account is LET IN (activate, resend its confirmation).
|
||||
*
|
||||
* Suspend and delete move the same counters and are deliberately NOT wired here yet — they have
|
||||
* their own pre-existing staleness, tracked separately, and widening this in passing would put an
|
||||
* unreviewed change in a review-fix diff.
|
||||
*
|
||||
* `me` is in the set because the admin badge counts pending activations, and `admin-pending-access`
|
||||
* because the queue is exactly the list of accounts this action removes one from — refreshing only
|
||||
* `admin-users` leaves the badge and the queue asserting a person is still stuck after they were
|
||||
* let in. One function so the two call sites cannot drift apart again. */
|
||||
export function invalidateUserAccess(qc: QueryClient, userId?: number) {
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
if (userId !== undefined) qc.invalidateQueries({ queryKey: qk.adminUserAccess(userId) });
|
||||
}
|
||||
|
||||
/** Did an admin resend fail because the instance has no working mail, rather than for a real
|
||||
* error? The admin-side resend deliberately reports failures (the public one is silent), so the
|
||||
* message must be able to say WHICH — a no-SMTP instance would otherwise read as a bug. */
|
||||
export function isEmailOff(err: unknown): boolean {
|
||||
if (!(err instanceof HttpError)) return false;
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { accountApi } from "./api/account";
|
||||
export type * from "./api/account";
|
||||
import { authApi } from "./api/auth";
|
||||
export type * from "./api/auth";
|
||||
export { loginBlocked, deadResetLink } from "./api/auth";
|
||||
import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
|
||||
@@ -10,12 +10,21 @@ export interface Me {
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
has_google: boolean;
|
||||
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
||||
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
||||
* `me+siftlode@gmail.com` keeps that address; this is the other one that also signs them in. */
|
||||
google_email: string | null;
|
||||
has_password: boolean;
|
||||
google_enabled: boolean;
|
||||
plex_enabled: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
/** Admin-only: sign-ups an admin still has to let in — approved BEFORE they registered, so nothing
|
||||
* ever reached the Access-requests queue and its badge read zero (roadmap UX-25). Counted apart
|
||||
* from pending_invites because they are different facts, and widening one number silently would
|
||||
* change what an existing field means. */
|
||||
pending_activations: number;
|
||||
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
||||
email_configured: boolean;
|
||||
preferences: Record<string, unknown>;
|
||||
|
||||
@@ -25,6 +25,24 @@ export interface Invite {
|
||||
/** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing
|
||||
* this row would NOT take access away. The delete confirmation says so. */
|
||||
still_allowed_by_env: boolean;
|
||||
/** When the admin last invited this person, or null if never. Adding an address to the whitelist
|
||||
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
|
||||
* act, and this is how the admin sees they have already done it. */
|
||||
invited_at: string | null;
|
||||
/** The language this person asked us in, or null when nobody ever told us — a row the admin
|
||||
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
|
||||
* language, so the guess is visible and correctable before anything is sent. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
|
||||
* one thing the client cannot work out for itself. */
|
||||
export interface PendingAccessRow extends AdminUserRow {
|
||||
/** The address is already allowed and the account is merely inactive, so Activate finishes a
|
||||
* decision the admin has already made. False when there is no approved invite behind it —
|
||||
* activating then would make an account that signs in with a password but is turned away by
|
||||
* Google, so the whitelist box is the honest move instead. */
|
||||
can_activate: boolean;
|
||||
}
|
||||
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
@@ -57,10 +75,26 @@ export interface AdminUserRow {
|
||||
email_verified: boolean;
|
||||
is_demo: boolean;
|
||||
has_password: boolean;
|
||||
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
|
||||
* account) from "we took it away" — different problems needing different help. */
|
||||
password_revoked_at: string | null;
|
||||
has_google: boolean;
|
||||
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
|
||||
google_email: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** The account panel's view of why someone can or cannot get in (E12 S5). */
|
||||
export interface AdminUserAccess extends AdminUserRow {
|
||||
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
|
||||
* fresh access request. */
|
||||
is_allowed: boolean;
|
||||
can_activate: boolean;
|
||||
/** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery
|
||||
* actions are offered or shown as unavailable — rather than failing after the click. */
|
||||
email_configured: boolean;
|
||||
}
|
||||
|
||||
export interface AuditRow {
|
||||
id: number;
|
||||
created_at: string | null;
|
||||
@@ -169,10 +203,36 @@ export const adminApi = {
|
||||
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
||||
req("/api/admin/demo/reset", { method: "POST" }),
|
||||
// --- access invites ---
|
||||
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
|
||||
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
|
||||
// Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing
|
||||
// was always possible by re-entering the address in the whitelist box.
|
||||
activateUser: (id: number): Promise<AdminUserRow> =>
|
||||
req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }),
|
||||
// Re-send someone's confirmation link. Unlike the public resend this one says what happened,
|
||||
// because the caller is already an authenticated admin.
|
||||
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
|
||||
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
|
||||
// What the account panel shows: the two computed gate answers, resolved for one user on open.
|
||||
adminUserAccess: (id: number): Promise<AdminUserAccess> => req(`/api/admin/users/${id}/access`),
|
||||
// Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the
|
||||
// everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password.
|
||||
createSetPasswordLink: (
|
||||
id: number,
|
||||
): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> =>
|
||||
req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }),
|
||||
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
||||
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
||||
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
|
||||
// this is the deliberate act that actually reaches a person.
|
||||
sendInvitation: (id: number, lang: string): Promise<Invite> =>
|
||||
req(
|
||||
`/api/admin/invites/${id}/invite`,
|
||||
{ method: "POST", body: JSON.stringify({ lang }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
addInvite: (email: string) =>
|
||||
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
|
||||
// session management, access requests, and the hidden demo login. Errors on the interactive paths
|
||||
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
import { req, uiLang } from "./core";
|
||||
|
||||
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
|
||||
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
|
||||
* BEFORE the password is proven and therefore carry nothing (they must stay identical for a real
|
||||
* account and an unknown one). Since E9.5 S1 the gates are order-independent — read both flags, never
|
||||
* infer one from the other. */
|
||||
export type LoginBlocked = {
|
||||
reason: "email_unverified" | "pending_approval" | "suspended" | "invalid" | "rate_limited";
|
||||
/** The address ON THE ACCOUNT — not the spelling that was typed. Absent for the two pre-proof
|
||||
* reasons. Naming it is what would have exposed the 2026-09-03 Gmail-alias mismatch in a second. */
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
approved?: boolean;
|
||||
/** Operator contact, when the instance publishes one (suspended accounts only). */
|
||||
operator?: string;
|
||||
};
|
||||
|
||||
/** Did a password-reset call fail because the LINK itself is finished — spent, expired, or belonging
|
||||
* to an account that no longer exists — rather than because the new password was too short? The two
|
||||
* arrive as the same 400, and only the first one means the form should come down. */
|
||||
export function deadResetLink(detail: unknown): boolean {
|
||||
const d = detail as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "reset" && d.reason === "invalid";
|
||||
}
|
||||
|
||||
const LOGIN_REASONS = new Set<LoginBlocked["reason"]>([
|
||||
"email_unverified",
|
||||
"pending_approval",
|
||||
"suspended",
|
||||
"invalid",
|
||||
"rate_limited",
|
||||
]);
|
||||
|
||||
/** Read a `{code:"login", …}` error detail into LoginBlocked, or null for anything else — including
|
||||
* a reason a NEWER server invented, which the card must fall back to a plain message for rather than
|
||||
* render as a blank panel. */
|
||||
export function loginBlocked(detail: unknown): LoginBlocked | null {
|
||||
const d = detail as Record<string, unknown> | null | undefined;
|
||||
if (!d || d.code !== "login") return null;
|
||||
const reason = d.reason as LoginBlocked["reason"];
|
||||
if (!LOGIN_REASONS.has(reason)) return null;
|
||||
return {
|
||||
reason,
|
||||
email: typeof d.email === "string" ? d.email : undefined,
|
||||
emailVerified: typeof d.email_verified === "boolean" ? d.email_verified : undefined,
|
||||
approved: typeof d.approved === "boolean" ? d.approved : undefined,
|
||||
operator: typeof d.operator === "string" ? d.operator : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
||||
@@ -12,12 +61,16 @@ export const authApi = {
|
||||
}> => req("/auth/config"),
|
||||
|
||||
// --- email + password (errors handled inline via quiet) ---
|
||||
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
|
||||
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
|
||||
// mail this person ever gets is written from what lands here.
|
||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||
req(
|
||||
"/auth/register",
|
||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
|
||||
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-login",
|
||||
@@ -30,6 +83,15 @@ export const authApi = {
|
||||
{ method: "POST", body: JSON.stringify({ email }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// Is a `#reset=` token still usable? Answers WITHOUT spending it, so the card can refuse to draw a
|
||||
// form that cannot succeed (a deleted account, an expired or already-used link). Advisory only —
|
||||
// confirmPasswordReset stays the authority, since the token can die between the two calls.
|
||||
checkPasswordReset: (token: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-reset/check",
|
||||
{ method: "POST", body: JSON.stringify({ token }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-reset/confirm",
|
||||
@@ -37,10 +99,15 @@ export const authApi = {
|
||||
{ quiet: true },
|
||||
),
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
// `password_reset_required` = SEC-10 dropped the password this account registered with (an admin
|
||||
// approved it before it was verified), so signing in would fail with the uniform credentials error.
|
||||
// The card sends the user to the reset flow instead of leaving them to guess.
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean; password_reset_required?: boolean }> =>
|
||||
// `password_reset_required` = SEC-10 dropped the password this account registered with, so signing
|
||||
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
|
||||
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
|
||||
// Google-adoption path, still reach it.
|
||||
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
|
||||
// matters when the browser holds a session for a DIFFERENT account.
|
||||
verifyEmail: (
|
||||
token: string,
|
||||
): Promise<{ ok: boolean; password_reset_required?: boolean; email?: string }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||
// reveals whether the email has a pending account.
|
||||
@@ -61,8 +128,13 @@ export const authApi = {
|
||||
),
|
||||
|
||||
// --- onboarding: access requests + demo entry ---
|
||||
// Same reasoning as register: the requester has no account to carry a language preference, so the
|
||||
// page they asked from is what decides the language of the approval or denial mail.
|
||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
req("/auth/request-access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, lang: uiLang() }),
|
||||
}),
|
||||
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
||||
// sign-in. Returns whether a session was established.
|
||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||
|
||||
@@ -8,6 +8,12 @@ import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
* every caller is one every caller can forget, which is how the registration language shipped as a
|
||||
* field nobody ever filled (E9.5 S6 → S7). */
|
||||
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
@@ -31,7 +37,10 @@ function localizeDetail(d: unknown): string {
|
||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||
// "check your input" message rather than leaking them into a translated sentence.
|
||||
if (Array.isArray(d)) return t("errors.validation");
|
||||
const o = d as { code?: string; reason?: string; limit?: number } | null | undefined;
|
||||
const o = d as
|
||||
| { code?: string; reason?: string; limit?: number; min?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
|
||||
@@ -46,10 +55,31 @@ function localizeDetail(d: unknown): string {
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
|
||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
if (o?.code === "reset")
|
||||
return o.reason === "weak_password"
|
||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
if (o?.code === "url_blocked")
|
||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// First-run install wizard: probes install state and drives admin/config/email setup with the
|
||||
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
|
||||
// the wizard renders errors inline. Only touches core's req + setupHeaders.
|
||||
import { req, setupHeaders } from "./core";
|
||||
import { req, setupHeaders, uiLang } from "./core";
|
||||
|
||||
export const setupApi = {
|
||||
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
|
||||
@@ -22,7 +22,11 @@ export const setupApi = {
|
||||
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/api/setup/test-email",
|
||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
|
||||
{
|
||||
method: "POST",
|
||||
headers: setupHeaders(token),
|
||||
body: JSON.stringify({ to, lang: uiLang() }),
|
||||
},
|
||||
{ quiet: true },
|
||||
),
|
||||
setupFinish: (token: string): Promise<{ ok: boolean }> =>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// The URL the app was OPENED with, captured once when this module is first imported — before any
|
||||
// component renders and before anything rewrites the address bar.
|
||||
//
|
||||
// Why a module-level snapshot rather than a `useState(() => …)` in whichever component needs it: the
|
||||
// 2026-09-02 incident was exactly this race. The nav-param mirrors mount ABOVE the lazily-loaded
|
||||
// <App>, and their mount effect replaceState'd the fragment away before AuthCard had snapshotted it,
|
||||
// so the emailed verification token was gone by the time anyone looked for it. 0.78.7 fixed that by
|
||||
// teaching urlWithParams/clearParams to carry the hash, but that leaves the guarantee resting on
|
||||
// every future URL writer remembering to do the same. Reading the entry URL once, at import time,
|
||||
// cannot lose the race no matter who mounts first or which chunk loads when.
|
||||
//
|
||||
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
|
||||
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
|
||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
||||
// with nothing personal in them (login, deleted) ride the query string.
|
||||
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
|
||||
/** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a
|
||||
* reload would re-show a notice about something that already happened (`access` only appears here
|
||||
* in its pre-S4 shape — it rides the fragment now). Deliberately NOT `link`,
|
||||
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
|
||||
* wizard, the lazily-mounted module views) do not run until several commits later. */
|
||||
const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
|
||||
|
||||
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
|
||||
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
|
||||
* second caller would be harmless but makes it unclear who is responsible.
|
||||
*
|
||||
* SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment
|
||||
* and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it
|
||||
* up to App (E9.5 S2) pointed it at every load. It then ran on the FIRST commit — before the setup
|
||||
* wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs,
|
||||
* and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took
|
||||
* `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is
|
||||
* somebody else's to clear, and `clearParams` is how they do it. */
|
||||
export function clearEntryUrl(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const dropped = ONE_TIME_QUERY.filter((k) => params.has(k));
|
||||
for (const k of dropped) params.delete(k);
|
||||
// A `#reset=` token that nothing has spent STAYS. Only the signed-out auth card consumes it, so in
|
||||
// a browser that already holds a session nobody reads it — and erasing it there turned the link
|
||||
// into a dead end with no explanation: the page loads, nothing happens, and clicking the link
|
||||
// again does the same, because the token is untouched and the session is still there. Leaving it
|
||||
// in the address bar keeps the recovery `main` had, where this strip lived inside the auth card:
|
||||
// sign out (which reloads, fragment intact) and the reset form is waiting. Once the card has used
|
||||
// it the token is spent, so a later reload meets S4's named dead-link screen — which is true.
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const keep = hash.has("reset") ? window.location.hash : "";
|
||||
if (!dropped.length && !window.location.hash) return;
|
||||
if (!dropped.length && keep) return;
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
// Preserved, not nulled: it carries scroll restoration and the channel entry.
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
|
||||
import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
|
||||
import en from "../i18n/locales/en/time.json";
|
||||
import hu from "../i18n/locales/hu/time.json";
|
||||
|
||||
@@ -108,3 +108,37 @@ describe("relativeFromMs", () => {
|
||||
expect(hu).toHaveProperty("justNow");
|
||||
});
|
||||
});
|
||||
|
||||
// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a
|
||||
// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no
|
||||
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
|
||||
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
|
||||
// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
|
||||
describe("formatRuntime", () => {
|
||||
const CASES: [number | null | undefined, string][] = [
|
||||
[null, ""],
|
||||
[undefined, ""],
|
||||
[0, ""], // nothing to show — the call sites suppress their separator on the empty string
|
||||
[1, "1s"],
|
||||
[45, "45s"], // one Short: the regression case
|
||||
[45.7, "45s"], // truncates, so the seconds tier cannot overstate either
|
||||
[59, "59s"], // last second before the minute tier
|
||||
[60, "1m"],
|
||||
[90, "1m"], // floors, never rounds up — a total must not overstate
|
||||
[90.9, "1m"],
|
||||
[3599, "59m"],
|
||||
[3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation
|
||||
[5025, "1h 23m"],
|
||||
[360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days
|
||||
];
|
||||
|
||||
it.each(CASES)("formats %o as %o", (sec, expected) => {
|
||||
expect(formatRuntime(sec)).toBe(expected);
|
||||
});
|
||||
|
||||
it("never reports a non-zero duration as zero", () => {
|
||||
for (let sec = 1; sec < 3600; sec++) {
|
||||
expect(formatRuntime(sec), `${sec}s`).not.toBe("0m");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,10 +96,16 @@ export function formatDuration(sec: number | null): string {
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
/** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths.
|
||||
* Empty string for a missing/zero duration (nothing to show). */
|
||||
/** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
|
||||
* length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the
|
||||
* Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two
|
||||
* shapes on one screen. Empty string for a missing/zero duration (nothing to show).
|
||||
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
|
||||
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a
|
||||
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
|
||||
export function formatRuntime(sec?: number | null): string {
|
||||
if (!sec) return "";
|
||||
if (sec < 60) return `${Math.floor(sec)}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
|
||||
@@ -85,7 +85,9 @@ export const qk = {
|
||||
|
||||
// --- admin ---
|
||||
adminUsers: () => ["admin-users"] as const,
|
||||
adminUserAccess: (id: number) => ["admin-user-access", id] as const,
|
||||
adminInvites: () => ["admin-invites"] as const,
|
||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||
adminConfig: () => ["admin-config"] as const,
|
||||
adminAudit: () => ["admin-audit"] as const,
|
||||
adminStorage: () => ["admin-storage"] as const,
|
||||
|
||||
@@ -14,6 +14,80 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.81.0",
|
||||
date: "2026-09-05",
|
||||
summary:
|
||||
"If you run your own Siftlode, this one is mostly for you: there is now a way back from a bad upgrade, backup scripts that actually restore, and a way to let someone in on an instance that cannot send mail. If you just use Siftlode, the visible part is that a download can no longer quietly blow past your storage limit, and that the box refuses addresses on the server's own network.",
|
||||
features: [
|
||||
"Admins get an account panel: click anyone in Users and it says, in one place, why that person can or cannot get in — whether their address is confirmed, whether an admin has approved them, whether they have a password, which Google account is attached, and whether their address is on the allow-list. Every recovery action for that account sits under the readout, so you pick one having read what is actually wrong instead of guessing.",
|
||||
'Someone locked out of an instance with no email setup can now be let back in. "Create a set-password link" gives you a one-time link to hand them however you like — chat, in person, on paper. It lasts 24 hours, works once, and signs out anyone already using that account. Issuing it changes nothing by itself: a misclick cannot lock anyone out.',
|
||||
"Self-hosters can pin a version. Put `IMAGE_TAG=0.81.0` in your `.env` and Siftlode stays there until you change the line, so an upgrade happens when you decide it does. The docs now also carry a rollback recipe — and say plainly that restoring the dump you took first is the way back that does not lose data.",
|
||||
"The backup and restore scripts work. On Windows they no longer corrupt the dump on the way out of the container, and on the documented self-host layout — the one where you download a single compose file — they now find it. The docs spell out that a backup is three things, not one: the database dump, your `.env` (without `TOKEN_ENCRYPTION_KEY` the restored secrets are unreadable), and your downloaded media.",
|
||||
"Plex works on the public compose files. The read-only media mount and its empty-volume fallback are in both of them, with a short setup section — before, the feature existed but the file you were told to run could not satisfy it.",
|
||||
"Siftlode is licensed, under AGPL-3.0.",
|
||||
],
|
||||
fixes: [
|
||||
"A download can no longer exceed your storage quota. The limit was only checked when you queued a job, so a single large download could pass that check and then fill the disk — and once you were at your cap, the download running at that moment was the one with no ceiling at all. Downloads are now bounded by what you have left, and a job that finds nothing left says so instead of running anyway.",
|
||||
"The download box refuses an address on the server's own network, and tells you why in your own language. Pasting an internal address used to make the server fetch it and hand the result back, which turned the box into a way to probe the machine Siftlode runs on. A malformed link is now refused as one rather than failing with a server error.",
|
||||
"One person holding down the sync button no longer spends the shared YouTube budget for everyone on the instance. Sync and playlist pull now say, in plain words, that you have synced a few times just now and that background syncing keeps running either way — a pace limit rather than a failure.",
|
||||
"A password-reset link opened in a tab you are already signed in to now says what to do with it. It used to do nothing at all: the page loaded, and the link appeared broken. The link itself stays in the address bar, so signing out from that same tab picks it up.",
|
||||
"Admin actions that fail now say so. Sending a confirmation link on an instance whose mail is misconfigured used to produce nothing on screen at all, which reads exactly like it worked.",
|
||||
"The row you click to open a user's account panel now looks clickable.",
|
||||
],
|
||||
chores: [
|
||||
"E12, five sprints. S1: the backup/restore scripts honour COMPOSE_FILE and the PowerShell pair moves the dump with `docker compose cp` rather than through the pipeline (binary does not survive it on 5.1, and `-Encoding Byte` does not exist on 7); docs gained an Upgrading section, and `siftlode release` now tags `v$VERSION` and refuses on a stale CHANGELOG.md. S2: AGPL-3.0, the Plex mount and `init: true` on the worker in both public compose files. S3 (SEC-12/13/14): a URL guard that resolves the host and refuses loopback/private/link-local at enqueue AND again in the worker, a generalised network-class error so it stops working as a port oracle, playlist_items/max_downloads/max_filesize derived from the caller's remaining quota, and a per-user cooldown on the manual sync triggers. S4: the three E9.5 deferrals (DX-16, DX-17, canonical resolution in the setup wizard). S5 (SEC-15/16): the account panel and the admin-issued set-password link, after a mockup round.",
|
||||
"The closing review ran six rounds across eight dimensions and converged 47 → 34 → 15 → 7 → 6 → 0. Round one's 47 deduplicated to 20 and 18 survived 60 adversarial verifier runs. Rounds two, four and five each found that a previous round's own fix had broken something — the ops half every time, because no gate can run shell scripts or a documented command. The three worth recording: the per-user quota refusal was writing onto a MediaAsset shared between users, so one person hitting their cap failed a second person's queued job for the same video with the first one's reason; the restore scripts' automatic restart re-ran `alembic upgrade head`, undoing the restore on the very rollback path the docs recommend; and the command printed in its place resolved to the other compose file, whose volumes differ, recreating the database empty. Two fixes were reverted rather than patched a third time. Round five also caught that the loop had been running seven of the gate's eight lanes. Residuals documented rather than closed: SEC-17 (redirects walk past the URL guard — the same socket-level fix as DNS rebinding), SEC-18 (the playlist push routes), SEC-19 (the edit path does not consult the byte cap).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.80.0",
|
||||
date: "2026-09-05",
|
||||
summary:
|
||||
"Getting into Siftlode used to take nine steps, three of which could quietly end in nothing — and one of which deleted the password you had just chosen. It is now one mail and one click in the usual case, every screen says which of the two gates you are actually waiting on, and the messages arrive in your own language.",
|
||||
features: [
|
||||
"Siftlode's emails are now written in Hungarian or English, whichever you use. Every message — confirming your address, an approval, a password reset, a role change, an account closure — follows the language you signed up in or later chose in the app. Nothing else on the site changes: a mail arrives once, in whatever language it was written in, which is the one place you cannot simply switch and re-read.",
|
||||
"Being approved usually takes one message now. If you have already signed up and your address is still unconfirmed, the approval mail carries the confirmation link itself and says so — you no longer have to find and open a second message that arrived hours earlier. When you have not signed up yet, it says a confirmation mail will follow, so its absence is something you would notice.",
|
||||
"The two gates — confirming your email and being let in by an admin — no longer have to happen in a set order, and the screen names the one you are actually waiting on, with the exact address it has on file. If your address needs confirming, it offers to send a fresh link on the spot.",
|
||||
'Signed in with Google under a different address than the one you registered with? Settings and the account menu now show both, so the pill saying "Connected" also says who you are connected as. It only appears when the two differ.',
|
||||
"Admins can see the people who signed up but still cannot get in, and which of the two gates each one is stuck behind. One click lets in someone whose address was approved before they registered, and a resend button gets a fresh confirmation link to anyone whose first one went astray — and says plainly if the instance has no working mail setup rather than reporting a send that never happened.",
|
||||
"Whitelisting an address stays silent, as it always has, but telling that person is now its own button — with a dialog that picks the language to write them in, and warns you if you have already written to them.",
|
||||
],
|
||||
fixes: [
|
||||
"The link in your confirmation email now works wherever you open it. In a browser already signed in to Siftlode it did nothing at all — the page loaded, the link was gone from the address bar, and nothing said whether it had worked; only a private window ever confirmed an address. Pasting the link into a tab that already had Siftlode open failed the same way.",
|
||||
"Gmail addresses that differ only by dots or a +tag are one mailbox again, and Siftlode treats them as one person. Approving hartmann.peter1986@ no longer leaves the account registered as hartmannpeter1986@ locked out, and signing in with Google no longer quietly files a second access request for someone who was approved weeks ago.",
|
||||
"The address you registered with is yours to keep. Signing in with Google used to overwrite it with the Google account's primary address, which cannot contain a +tag — so a me+siftlode@gmail.com sign-up lost its tag and could no longer sign in with the address it had chosen. Password resets and confirmation resends now reach you under any spelling of your mailbox, and the mail goes to the address on your account.",
|
||||
'An admin approving your account no longer deletes the password you chose at sign-up. Because approval usually happens before you read your mail, "your password was cleared for security" had become the normal experience rather than the rare one.',
|
||||
'Signing up again with an address that was already registered but never confirmed sends the confirmation link again. It used to answer with the same cheerful "check your inbox" and send nothing at all, which is the likeliest explanation for confirmation mails that "never arrived".',
|
||||
'Opening a password-reset link that has expired or was already used no longer draws the full "set a new password" form for you to fill in before telling you. It says the link is spent and offers a new one. The check is done without spending the link, so simply opening it costs you nothing.',
|
||||
"A refused sign-in explains itself in your language instead of showing a raw English message, and the emails now say how long their links last and that they work only once.",
|
||||
"The three messages you can be left waiting on now name the spam folder, because the first mail to a brand-new address is the one most likely to land there.",
|
||||
],
|
||||
chores: [
|
||||
"E9.5, seven sprints. S1 removed the SEC-10 revoke from the admin-approval path (it stays on Google adoption, where ownership is attested). S2 moved verify-token consumption above App's signed-in/signed-out branch and snapshots the entry URL at import time, so no later URL writer can steal it; a hashchange listener covers fragment-only navigation, which UAT found on a plain-text mailed link. S3 added utils.canonical_email + a CanonicalEmailMixin deriving users/invites.email_canonical on every write (migration 0079), swept all eight matching sites, and kept the typed address for delivery; users.google_email (0080) surfaces the second address only when it differs. S4 gave every password_login refusal the {code, reason} shape and added a non-consuming password-reset/check. S5 added can_activate/count_pending_activations in core plus the admin surface and a badge that counts what actually waits on an admin. S6 branched the approval mail on what is left to do (7-day token) and recorded invites.invited_at (0081). S7 moved twelve mails into app/mailtext.py with a parity test over keys AND placeholders, resolved the language in the request handler rather than the background task, and captured invites.lang (0082) so a decision mail answers in the language the request was made in.",
|
||||
"The closing review ran four rounds across seven dimensions and converged 12 → 4 → 3 → 2, every finding adversarially verified. It caught that clearEntryUrl had been blanking the whole query string on every load — which would have shipped a broken first-run setup — that the env allow-list still matched literally while invites had gone canonical, that invited_at recorded the intent rather than the send, and that three rewritten mailers never rendered in any test. It also refuted S1's own argument that the approval-time revoke was redundant, and then, in round 2, refuted the round-1 fix for that: \"the newest registration owns an unverified account\" is symmetric, so an attacker can simply register last, and it handed anonymous callers a write onto a real account's password — including the rows SEC-10 had deliberately left password-less. Reverted, with the attempt written into the roadmap so it is not tried again; the residual is SEC-16 and the owner's recourse remains password-reset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.79.0",
|
||||
date: "2026-09-03",
|
||||
summary:
|
||||
"A playlist now tells you how long it is, and its header takes half the room it used to — so you see more of the list and less of the frame around it.",
|
||||
features: [
|
||||
'Every playlist header shows the total playtime beside the video count: "32 videos · 2h 17m". It is the whole playlist, not whatever a filter is currently showing, so it answers the question you actually have — is there time for this tonight?',
|
||||
"The header was three stacked rows next to a large cover; it is two tight ones next to a smaller cover. Nothing was hidden behind a menu to get there — the title, rename, the YouTube link, Play all, the sync and delete buttons, group-by-channel, the view controls and undo are all still on screen. That band never scrolls, so the height it gave back is list you can now see: about eighty pixels on a normal window.",
|
||||
"Plex playlists show their total playtime in the same place, in the same words.",
|
||||
],
|
||||
fixes: [
|
||||
"The column header no longer jolts upward the instant you start scrolling a playlist. It used to sit twelve pixels below the top of the list and snap flush on the first scroll, which read as the page flinching.",
|
||||
'A playlist’s length is written one way everywhere now. The list on the left said "2:15:30" while the header beside it said "2h 15m" for the very same playlist — one number in two shapes, side by side.',
|
||||
'A playlist shorter than a minute reported "0m". A single Short is 45 seconds long, and the header said it had no runtime at all; it now says "45s".',
|
||||
'A Plex playlist holding exactly one thing said "1 items" in English.',
|
||||
],
|
||||
chores: [
|
||||
'12D reads the durations already in hand rather than adding an endpoint: both headers reduce over their own items, and formatRuntime grew a sub-minute tier because an aggregate is the first input that can legitimately land between 1 and 59 seconds — the fix lives in the function, not behind a `>= 60` guard at each of its seven call sites. PlaylistsRail moved off formatDuration onto the same helper so a playlist total has one renderer. 12A keeps every control and its gating (`items.length > 1 || viewActive`, editable/canWrite/canEdit, linked && dirty) byte-for-byte; 12B drops the wrapper’s pt-3 and passes fadeTop:false for the playlists page, matching the channel table. formatRuntime had no unit tests across seven call sites and now has a tier table plus a sweep asserting no non-zero second ever renders as "0m". Epic-end deep review over five dimensions: eighteen raw findings, ten distinct, two dropped after 3-of-3 adversarial refutations (meta-line segment order; the null-duration lower bound, which matches the server’s own long-shipped SUM), six fixed, two logged as deferrals. Rounds converged 6 → 1 → 0.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.78.8",
|
||||
date: "2026-09-02",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { HttpError } from "./api/core";
|
||||
import { clearEntryUrl, entryHash } from "./entryUrl";
|
||||
|
||||
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
|
||||
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
|
||||
* error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from
|
||||
* `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling
|
||||
* someone their link is spent when the server was merely restarting sends them to request a fresh one
|
||||
* on the strength of a false statement. The same rule is already written one file away, for the
|
||||
* reset-link probe: "a network blip must never be cached as 'this link is dead'". */
|
||||
type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable";
|
||||
|
||||
export type VerifyEntry = {
|
||||
outcome: VerifyOutcome | null;
|
||||
/** The address that was confirmed, when the server named it. */
|
||||
email: string | null;
|
||||
/** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card
|
||||
* consumes a reset token, so nothing here can act on it — but silence is what made the verify half
|
||||
* a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately
|
||||
* left in the address bar (E9.5 review round 4), so signing out still recovers it. */
|
||||
resetPending: boolean;
|
||||
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
|
||||
dismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm a `#verify=` token from the entry URL, wherever the user opened it.
|
||||
*
|
||||
* App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be
|
||||
* consumed inside Welcome.tsx, and `App.tsx` renders <Welcome /> only when there is no session. Open
|
||||
* the emailed link in a browser that already holds a Siftlode session and the token was never posted,
|
||||
* the fragment was then stripped, and the user got no signal at all — the link simply did nothing.
|
||||
* Incognito worked, which is why it survived so long.
|
||||
*
|
||||
* The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL
|
||||
* differs only in the fragment, the browser performs a same-document navigation — no reload, so the
|
||||
* import-time snapshot never sees the new token. Pasting the emailed link into a running tab's address
|
||||
* bar does exactly that, and our verification mails were plain text until 0.78.8, which makes pasting
|
||||
* the normal way to use one. Without the `hashchange` listener below the token would sit unconsumed in
|
||||
* the address bar: the same silence signed-in users met before, in a second disguise.
|
||||
*
|
||||
* Each token is posted exactly once. It is single-use, so a repeat POST (a StrictMode remount, any
|
||||
* re-render) would 400 and wrongly flip a successful confirmation to "invalid".
|
||||
*/
|
||||
export function useVerifyEntry(): VerifyEntry {
|
||||
const [token, setToken] = useState<string | null>(() => entryHash.get("verify"));
|
||||
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [resetPending, setResetPending] = useState<boolean>(() => !!entryHash.get("reset"));
|
||||
const consumed = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const next = hash.get("verify");
|
||||
if (next) setToken(next);
|
||||
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
|
||||
// same-document navigation the import-time snapshot never sees. The verify half got this
|
||||
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
|
||||
//
|
||||
// Set from the live hash rather than one-way, because the flag must not outlive the token it
|
||||
// describes: navigating away from a `#reset=` (a pasted verify link, the back button) leaves
|
||||
// the banner promising that signing out will resume a reset whose token is gone — and, being
|
||||
// checked first, hiding whatever arrived in its place. `clearEntryUrl`'s own replaceState
|
||||
// fires no hashchange, so this cannot undo the deliberate keep-the-reset-fragment behaviour.
|
||||
setResetPending(!!hash.get("reset"));
|
||||
};
|
||||
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
||||
// feed itself.
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear the address bar even with no token to consume, so a spent one leaves no trace on a
|
||||
// reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a
|
||||
// signed-in tree reads it, so erasing it there is a dead end rather than a cleanup.
|
||||
clearEntryUrl();
|
||||
if (!token || consumed.current.has(token)) return;
|
||||
consumed.current.add(token);
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((r) => {
|
||||
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
|
||||
setEmail(r.email ?? null);
|
||||
})
|
||||
// Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent
|
||||
// or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped
|
||||
// connection (no HttpError at all), says nothing about the token and must not be reported as
|
||||
// if it did. The token itself is unspent in that case — but the fragment has already been
|
||||
// cleared, so the honest message is "we could not reach the server", not "request a new link".
|
||||
.catch((e: unknown) => {
|
||||
const spent =
|
||||
e instanceof HttpError &&
|
||||
e.status === 400 &&
|
||||
(e.detailData as { code?: string } | undefined)?.code === "verify";
|
||||
setOutcome(spent ? "invalid" : "unreachable");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return {
|
||||
outcome,
|
||||
email,
|
||||
resetPending,
|
||||
// Dismiss peels ONE layer. The two states are independent — a reset link can arrive over an
|
||||
// unread confirmation, and a slow /auth/verify can resolve after one did — so clearing both at
|
||||
// once silently discards whichever the user had not read yet. The banner shows the reset
|
||||
// guidance first (it is the one with something to do), and dismissing it reveals the outcome
|
||||
// underneath rather than taking it with it.
|
||||
dismiss: () => {
|
||||
if (resetPending) {
|
||||
setResetPending(false);
|
||||
return;
|
||||
}
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The emailed verification link, confirmed wherever it is opened. The regression this pins: the token
|
||||
// used to be consumed inside Welcome.tsx, which App renders ONLY when there is no session — so opening
|
||||
// the link in a browser that already held a Siftlode session posted nothing, the fragment was then
|
||||
// stripped, and the user got no signal at all. Incognito worked, which is why it survived.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so each case resets the module registry and sets
|
||||
// the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the hook at a given entry URL with a stubbed /auth/verify, and hand back what it reported. */
|
||||
async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
// Carries whatever state the case set up: history.state is part of what the hook must not destroy.
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("./api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api")>();
|
||||
return { ...actual, api: { ...actual.api, verifyEmail } };
|
||||
});
|
||||
const { useVerifyEntry } = await import("./useVerifyEntry");
|
||||
|
||||
function Probe() {
|
||||
const entry = useVerifyEntry();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>
|
||||
<div data-testid="reset">{entry.resetPending ? "reset-pending" : "-"}</div>
|
||||
<button data-testid="dismiss" onClick={entry.dismiss} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
render(<Probe />);
|
||||
});
|
||||
return () => screen.getByTestId("outcome").textContent;
|
||||
}
|
||||
|
||||
describe("confirming an emailed verification link", () => {
|
||||
it("posts the fragment token and reports the confirmed address", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-123", verifyEmail);
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("tok-123");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
});
|
||||
|
||||
it("posts exactly once, so a re-render cannot spend the single-use token twice", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "once@example.com" });
|
||||
|
||||
await mountAt("/#verify=tok-once", verifyEmail);
|
||||
|
||||
// A second POST would 400 on the already-consumed token and flip a success to "invalid".
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports ok_reset when the account's password had been revoked", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, password_reset_required: true, email: "revoked@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-reset", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("ok_reset:revoked@example.com");
|
||||
});
|
||||
|
||||
// Errors are built INSIDE the rejection, not hoisted above the test: mountAt calls
|
||||
// vi.resetModules(), so an HttpError imported earlier would be a different class identity than the
|
||||
// one the hook imports afterwards, and `instanceof` would quietly miss.
|
||||
const rejectWith = (make: (E: typeof import("./api/core").HttpError) => unknown) =>
|
||||
vi.fn().mockImplementation(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw make(HttpError);
|
||||
});
|
||||
|
||||
it("reports invalid when the SERVER says the link is expired or already used", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(400, "verify", { code: "verify" }));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
// DX-16: the fragment is already cleared by the time this resolves, so reporting a transport
|
||||
// failure as "that link is no longer valid" pushes the user into requesting a fresh link on the
|
||||
// strength of a statement that is false — the token is untouched and retrying it works.
|
||||
it("does not call the link dead on a gateway failure mid-deploy", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(502, "Bad Gateway"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-live", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
it("does not call the link dead when the connection drops with no HTTP status at all", async () => {
|
||||
const verifyEmail = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-offline", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
// A 400 that is not the verify refusal is still not a verdict on the token.
|
||||
it("does not call the link dead on a 400 that is not the verify refusal", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(400, "nope", { code: "other" }));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-other", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
it("clears the one-time query flags it owns even with nothing to confirm", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
const outcome = await mountAt("/?access=requested", verifyEmail);
|
||||
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
expect(outcome()).toBe("none:-");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
// Only the signed-out auth card consumes a `#reset=` token, so in a browser that already holds a
|
||||
// session nobody reads it — and erasing it turned the link into a dead end with no explanation:
|
||||
// the page loads, nothing happens, and clicking it again does the same, because the token is
|
||||
// untouched and the session is still there. Leaving it keeps the recovery: signing out reloads
|
||||
// with the fragment intact and the reset form is waiting.
|
||||
it("leaves an unspent reset link in the address bar", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
await mountAt("/?login=out#reset=unspent", verifyEmail);
|
||||
|
||||
expect(window.location.hash).toBe("#reset=unspent");
|
||||
expect(window.location.search).toBe(""); // the flag it does own still goes
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// DX-17: the verify half got a hashchange listener in E9.5 S2, the reset half did not — so pasting
|
||||
// a reset link into a tab already running the app was silently ignored. Our mails were plain text
|
||||
// until 0.78.8, which makes pasting the normal way to use one.
|
||||
it("notices a reset link pasted into a tab that is already running", async () => {
|
||||
await mountAt("/", vi.fn());
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#reset=pasted");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// The flag must not outlive the token it describes. Navigating away from a `#reset=` fragment —
|
||||
// a pasted verify link, the back button — leaves the address bar with no reset token, so a
|
||||
// still-true flag promises a recovery that signing out can no longer complete AND (being checked
|
||||
// first by VerifyBanner) hides whatever arrived in its place.
|
||||
it("drops the reset notice once the fragment carrying it is gone", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#reset=going-away", verifyEmail);
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#verify=tok-next");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
});
|
||||
|
||||
// …and a fragment change that carries neither token drops it too (the back button out of a
|
||||
// reset link), which a one-way "set it when you see one" listener would not.
|
||||
it("drops the reset notice when the fragment changes to no token at all", async () => {
|
||||
await mountAt("/#reset=going-away", vi.fn());
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#somewhere-else");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
});
|
||||
|
||||
// A reset link can arrive over an unread confirmation (and a slow /auth/verify can resolve after
|
||||
// one did), so both states can be set at once. Neither may eat the other: `dismiss` used to clear
|
||||
// both, which is how the reset guidance could be destroyed before it was ever rendered.
|
||||
it("keeps a pasted reset link and an unread confirmation, and dismisses them one at a time", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-first", verifyEmail);
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#reset=pasted");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
// Both live: the reset is pending AND the confirmation is still unread.
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
// One dismiss peels the reset guidance and leaves the confirmation standing…
|
||||
await act(async () => screen.getByTestId("dismiss").click());
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
// …and the next one clears that.
|
||||
await act(async () => screen.getByTestId("dismiss").click());
|
||||
expect(outcome()).toBe("none:-");
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
// ?link=/?oauth= effects and the lazily-loaded module views read their own parameters. It used to
|
||||
// blank the whole query and null history.state, which silently broke first-run setup (the wizard
|
||||
// found no ?token=) and every signed-in deep link. It must only clear what it owns.
|
||||
it("leaves parameters it does not own, and keeps history.state", async () => {
|
||||
window.history.replaceState(
|
||||
{ sfScroll: 42 },
|
||||
"",
|
||||
"/?token=setup-1&page=plex&v=show:9&login=out",
|
||||
);
|
||||
|
||||
await mountAt(window.location.href, vi.fn());
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("token")).toBe("setup-1");
|
||||
expect(params.get("page")).toBe("plex");
|
||||
expect(params.get("v")).toBe("show:9");
|
||||
expect(params.get("login")).toBeNull(); // a one-time notice, ours to drop
|
||||
expect(window.history.state).toEqual({ sfScroll: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// The link does not always arrive as a page LOAD. Found in UAT on 2026-09-03: navigating a tab that
|
||||
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
|
||||
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
|
||||
// address bar — the same silence the sprint set out to remove, wearing a different hat.
|
||||
describe("a verification link that arrives as a fragment-only navigation", () => {
|
||||
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
|
||||
const outcome = await mountAt("/", verifyEmail);
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=pasted-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
|
||||
expect(outcome()).toBe("ok:pasted@example.com");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
|
||||
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
|
||||
// token is spent, and this case is about the SECOND outcome replacing the first.
|
||||
.mockImplementationOnce(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw new HttpError(400, "verify", { code: "verify" });
|
||||
});
|
||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
||||
expect(outcome()).toBe("ok:first@example.com");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=spent-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
it("posts a given token once even if the same fragment arrives again", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
|
||||
await mountAt("/#verify=same-token", verifyEmail);
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=same-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user