diff --git a/app/THIRD_PARTY.md b/app/THIRD_PARTY.md new file mode 100644 index 0000000..4a99ad2 --- /dev/null +++ b/app/THIRD_PARTY.md @@ -0,0 +1,32 @@ +# Drittanbieter-Material in der App + +## SecLists (Passwortliste) + +Betrifft `assets/security/common-passwords.txt` (Herkunft und Filter: +`assets/security/README.md`). Lizenz: MIT, Wortlaut wie im Repository +https://github.com/danielmiessler/SecLists (Commit +`68bcd12457978649d41ae4f56e3b5d2db2b72560`): + +```text +MIT License + +Copyright (c) 2018 Daniel Miessler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/app/assets/security/README.md b/app/assets/security/README.md new file mode 100644 index 0000000..ad8625b --- /dev/null +++ b/app/assets/security/README.md @@ -0,0 +1,45 @@ +# Liste häufiger Passwörter + +`common-passwords.txt` wird von der Registrierung und vom Passwort-Reset genutzt +(KTO-01 Kriterium 3, Spec #1 § 5.6). Die Liste wird nur im Client geprüft; es +gibt keinen Abgleich mit externen Diensten (kein HIBP). + +## Format + +- Eine Zeile je Eintrag, UTF-8, LF, Kleinschreibung, ohne Leerraum am Rand. +- Nur Einträge mit mindestens 10 Zeichen (kürzere fängt die Längenregel). +- Einmalig, nach Häufigkeit sortiert (deutsche Einträge zuerst). +- 10.000 Einträge, ca. 118 KB. +- Der Vergleich in der App erfolgt ohne Groß-/Kleinschreibung und nach Trim. + +## Quelle und Lizenz + +SecLists von Daniel Miessler, Lizenz MIT (Volltext in `app/THIRD_PARTY.md`), +Commit `68bcd12457978649d41ae4f56e3b5d2db2b72560`, Verzeichnis +`Passwords/Common-Credentials/`: + +| Datei | Verwendung | +|---|---| +| `Language-Specific/German_common-password-list-top-100000.txt` | die ersten 4.000 Einträge mit mindestens 10 Zeichen | +| `xato-net-10-million-passwords-1000000.txt` | die ersten 6.000 weiteren Einträge mit mindestens 10 Zeichen | + +**Lizenzfrage entschieden (Chris, 2026-11-03):** Die MIT-Lizenz gilt für das +SecLists-Repository. Die einzelnen Listen stammen aus Datenlecks bzw. +Sammlungen Dritter (die xato-Liste von Mark Burnett); eine eigene Lizenz je +Liste nennt SecLists nicht. Chris hat entschieden, die SecLists-Listen mit +dokumentierter Herkunft (diese Datei) und MIT-Lizenztext (`app/THIRD_PARTY.md`) +zu nutzen. Die Liste enthält ausschließlich Passwörter, keine Konten oder +E-Mail-Adressen. + +Der Lizenztext wird zur Laufzeit über `LicenseRegistry` ausgeliefert +(`lib/core/licenses/third_party_licenses.dart`, Asset `THIRD_PARTY.md`) und +erscheint damit in `showLicensePage`. + +## Neu erzeugen + +```bash +python3 scripts/build-common-passwords.py app/assets/security/common-passwords.txt +``` + +Das Skript liegt unter `scripts/build-common-passwords.py` (Filter, Reihenfolge +und Grenzen stehen im Kopf der Datei). diff --git a/app/assets/security/common-passwords.txt b/app/assets/security/common-passwords.txt new file mode 100644 index 0000000..c0baa47 --- /dev/null +++ b/app/assets/security/common-passwords.txt @@ -0,0 +1,10000 @@ +huhbbhzu78 +1234567890 +y57gjng4gh +lincolnboy11 +ichliebedich +1q2w3e4r5t +sonnenschein +maximilian +musikverein +sonnenblume +qwertzuiop +hannover96 +qwertyuiop +deutschland +88ggox8oug +schokolade +alexander1 +passwort123 +123456789a +vqsablpzla +porsche911 +hallohallo +arschloch1 +undertaker +64t3zawonz +jolinachiara1 +dortmund09 +dlnns73x7p +christian1 +y52flzfq8v +val353nxhc +sdfsb5fgikd +y57e4lukgh +p0fjvv2d5969 +hallo12345 +marcus1988 +defaultpassw0rd +sebastian1 +q1w2e3r4t5 +12345678910 +borussia09 +deinemutter +regenbogen +basketball +deinemudda +hurensohn1 +prinzessin +werderbremen +wassermann +0123456789 +hellokitty +linkinpark +123456789! +sternchen1 +gapostar5000 +dragonball +feuerwehr112 +fcschalke04 +harrypotter +einslive1aa +willkommen +bademeister +nabilnabil94 +1q2w3e4r5t6z +florian123 +keineahnung +12345qwert +norbert007 +schmetterling +pokemon123 +australien +aggroberlin +christopher +katharina1 +diosesfiel +cjmasterinf +bundeswehr +denis.1101 +vergessen1 +passwort12 +qwert12345 +maximilian1 +12345678958 +kindergarten +original75! +0987654321 +1337tucker +a123456789 +hellabaer2 +blumentopf +1234567891 +hallo1234567 +hallihallo +1123581321 +fenerbahce +spocht1621 +volkswagen +calistus12 +1111111111 +tokiohotel +sojdlg123aljg +12345678911 +samsung123 +terminator +sonnenschein1 +ichliebedich1 +computer123 +rammstein1 +lpz93ssskqw8q +dortmund1909 +sz9kqcctwy +finalfantasy +skateboard +christoph1 +schokolade1 +jacqueline +christiane +password123 +metallica1 +feuerwehr1 +mausilein44 +enterprise +galatasaray +ichbincool +arschloch123 +zaubermaus +flocke1234 +loveline50 +120681b316312 +darius0404 +flensburg88 +weihnachten +schnuffel1 +playstation3 +q1w2e3r4t5y6 +schneemann +andreas123 +jamesbond007 +spongebob1 +sicherheit +1a2b3c4d5e +123qweasdyxc +h54rsjrf5j46788998 +jochen1949 +kakashid60 +meinschatz +sagichnicht +motherfucker +sommer2010 +ichliebedich123 +123456789123 +justinbieber +miachiara1 +deutschland1 +123456789q +passwort11 +manchester +sonnenblume1 +jkiuztdftl57 +hanswurst1 +mondschein +ilshofen0926 +frankreich +bundesliga +schalke1904 +habdichlieb +volleyball +engelchen1 +barcelona1 +leverkusen +affenarsch +hurensohn123 +krombacher +minecraft1 +battlefield +sanandreas +cameroon12345 +asdfghjkl1 +playstation +zimmermann +dominik123 +al1980|4520 +cinderella +willkommen! +geheimladen +12345678123 +christina1 +starwars123 +patrick123 +bandit1200 +0000000000 +alexander123 +steffen123 +yxcvbnm123 +!janine2006y +minecraft123 +yagjecc826 +hamster123 +lolipop123 +harlaching8 +ronaldinho +schlagzeug +vanessa123 +fickdich123 +purzel2912 +renerochel08 +schneider1 +davben1236s!d3 +siemens123 +michelle123 +1234554321 +starcraft2 +hallohallo1 +harlaching1 +diesonne10 +schalke04! +motherlode +neuseeland +herrderringe +butterblume +hamburg123 +9876543210 +tischtennis +stuttgart1 +rickroll22 +1234567890! +konstantin +counterstrike +1234567857 +12fabian12 +frankfurt1 +sternschnuppe +christine1 +alexandra1 +wasserfall +1234512345 +apfelkuchen +underground +handball123 +realmadrid +dachdecker +realschule +superman123 +1qay2wsx3edc +freundschaft +waswaswas1 +pusteblume +vrbgqns997 +eintracht1 +teufelchen +alessandro +2beijaflor +ballermann +callofduty +borussia1900 +pillepalle +michael123 +freunde123 +lieselotte +highscreen +harlaching7 +polizei110 +jessica123 +fussball11 +luckystrike +meerschweinchen +franziska1 +hamburg1887 +dewa123123 +porsche123 +california +nopassword +mamapapa123 +master2006 +ersguterjunge +hundekuchen +soso123aljg +softwaremagazine +ichhassedich +laozhu5210 +iceolator007 +germany2020 +elisabeth1 +silbermond +onedirection +qaywsxedc123 +qwertz1234 +jasmin1987 +fuckyou123 +heidelberg +mamaundpapa +deinemudda1 +123polizei +ka_djkhjsy6 +bushido123 +roadrunner +wassermelone +tannenbaum +1235755280 +hayabusa2007 +meerschwein +a1b2c3d4e5 +warrock123 +kasachstan +schottland +hevhk43n9j +schatzi123 +geburtstag +abcde12345 +swordfish1 +hamburgersv +philipp123 +123456789m +groupd2013 +nissan350z +fussball123 +iloveyou123 +istanbul34 +1v7upjw3nt +ichliebemich +rottweiler +onkelz2000 +ghostrider +mittelerde +schweinchen +nathalie95 +halligalli +highlander +paulinchen +sabrina123 +ichbinich82 +fledermaus +ficken2009 +playstation2 +lokomotive +2009-06-0116 +aeroxsony1 +donnerstag +underworld +computer12 +playboy123 +18pascal09 +benjamin123 +waelderhexen +lausmaus20 +charlotte1 +2009-08-1609 +schumacher +rosenstolz +werder1899 +0000-00-0000 +kamikaze06 +fussel1234 +dynamo1953 +sidewinder +leuchtturm +christian123 +tm-kontakt23 +cimbom1905 +killer1992 +joshuawill +sommer2012 +minicooper +internet123 +mastermind +battlefield2 +regenbogen1 +killer1234 +pitbull123 +borussia123 +sommer2006 +trabant601 +12345t6789 +callofduty4 +ma14121982 +1122334455 +schweini31 +blutwurst1 +bruderherz +hallo123456 +flowerpower +stephanie1 +meister123 +warhammer1 +undercover +hammerfall +herakles1243 +siegheil88 +warhammer40k +4815162342 +hallo12352 +nudelsuppe +ronaldo123 +monster123 +undertaker1 +hoffenheim +bayern2010 +swordcoast67 +florentine +sternchen123 +desperados +gth7u6y5t43!az +dragonball1 +daniel1991 +alleswirdgut +johannes123 +rungring68 +kleinemaus +password12 +hausmeister +liverpool1 +sommer2009 +ichmagdich +muslim1990 +schutzengel +prinzessin1 +katzenklo1 +1029384756 +myspace123 +stronghold +1a2s3d4f5g +b9c74f8e0291d43c6ef60de18df625cc +1234567811 +schwimmen1 +hausaufgaben +tinkerbell +hannelore1 +123456789b +123456789!! +schneeball +fickdich12 +2009-09-0520 +ichliebedi +schweinsteiger +6r8u2vvbnm +millhouse1 +arschgesicht +fussball10 +basketball11 +salamander +supermario +bochum1848 +ironmaiden +mathematik +butterfly1 +telefon123 +painkiller +darthvader +tempesth1941 +hitachi222 +7ugd5hip2j +3rjs1la7qe +radeberger +blablabla1 +michelle99 +123456789123456789 +pep4teens! +bajaonel12 +qwertzu123 +melanie123 +arschloch2 +simpsons123 +scheisse123 +webhompass +3odi15ngxb +arschloch! +123qweasdzxc +dok55dok55 +gwerty123!! +blokkmonsta +18hibird00 +gesundheit +slipknot666 +schalke045 +7894561230 +1qaz2wsx3edc +backhausstr.8 +niebeimir11 +password1! +g_czechout +spellforce +1q2w3e4r123 +q1w2e3r4t5z6 +software1! +nintendo64 +bestfriends +123qwe123qwe +1234567890123 +deinemudder +starcraft1 +peugeot206 +qwertzuiop1 +123456789abc +taschentuch +askimbenim +evangelion +passwort111 +marcel1996 +hallohallo123 +hildesheim +kugelfisch +12345678901 +leberwurst +zag12wsx123 +cocacola123 +sunshine12 +wnmurr0555 +berlin2000 +alexander11 +tiboranna123 +armageddon +fckgwrhqq2 +qwerty12345 +bratwurst1 +password01 +borussia1909 +janine2006y +langweilig +schneewittchen +koolsavas1 +schwuchtel +newlife2006 +himmelblau +mama123ily +sauerkraut +sweetheart +1234567890!! +1red2bull3 +deinemutter1 +schalke0453 +kiki28seto +mh99441488 +daniela123 +kennwort123 +regensburg +larissa123 +littledave94 +valentina1 +schalke123 +schneider123 +abcdefg123 +123456123456 +reymysterio +simsalabim +schandmaul +langeweile +luftballon +australien1 +sunshine123 +metalman1990 +christian! +2009-09-05 +badewanne1 +qaywsxedc1 +n00bkiller +warcraft123 +westerland +schlampe123 +motorenbau +nurmir11051943 +2009-06-01 +coolman123 +sebastian! +alexander! +mostwanted +sebastian123 +daniel1995 +ronaldinho10 +nessless11 +reymysterio619 +currywurst +qweasdyxc123 +qwertzui123 +dennis1234 +wasgehtab123 +12345678aa +deinemudda123 +sonnenschein123 +maximilian123 +passwort1234 +hardstyle1 +sonybravia2015 +logitech123 +flasche123 +wrestling1 +chinchilla +pikeplus123 +kartoffel1 +password1!! +horst281165 +claudia123 +sternenhimmel +qpwoei719801 +karolina1894 +daniel1996 +dinosaurier +vfbstuttgart +ludosozing12 +gammel0815 +linkinpark1 +limpbizkit +klarinette +alexander12 +hondacivic +papierkorb +skinhead88 +meinpasswort +starwars12 +terminator1 +bundeswehr1 +hundefutter +hellokitty1 +skywalker1 +ndtoi91navzondv6sgptspn3n +vanilla1337 +staudenmayer1 +km900arena +blumenwiese +mineralwasser +6hbf28w791 +sternchen2 +dragon12345 +lovenico82 +celine2000 +mannheim68 +summertime +pennergame +geschichte +wasser1234 +sugar0815free +opelvectra +dreizehn13 +1262001leon +brokentiger444 +1234567890a +fussball12 +daniel2000 +lamborghini +erzincan24 +dmameth666 +bettler123 +aggroberlin1 +slipknot66 +videoseven +topsecret1 +skateboard1 +1234567123 +lassmichrein +butterbrot +stundenplan +lichtbogen +singh38116 +master1234 +charlie123 +goldfisch1 +supermann1 +1g2w3e4r!! +1q2w3e4r5t6y +battlefield3 +0190666666 +welcome123 +hesoyam123 +sommer2016 +peterpan123 +berlin2008 +spongebob123 +schreibtisch +london2009 +casablanca +1g2w3e4r123 +nothings123 +12345678!! +keineahnung1 +s123456789 +matthias12 +eh1k9oh335 +basshunter24 +sagichnich +nikolas123 +asdfghjkl123 +barbara1985 +silvester1 +skateordie +angelina123 +gabriel123 +lionkingselby12 +f99eiau1wtlu1so7fd241zwxa +spiderman1 +meinekinder +m123456789 +lol123456789 +internet01 +christopher1 +friederike +schnitzel1 +1234567899 +sennenhund +matthias123 +josephine1 +mietzekatze +hannover1896 +puenktchen +morgenstern +ronny1978. +hendrik123 +daniel1234 +belladonna +mittelalter +schachmatt +apfelschorle +aschenbecher +ichduersiees +selenagomez +deinemutter123 +sommer2017 +melissa123 +123456789k +selenagomez1 +spieleaffe +achterbahn +florian112 +frauenarzt +hammerhart +mamapapa12 +alexander99 +asdfg12345 +lisa-marie +microsoft1 +smackdown1 +ichbincool1 +sailormoon +manuel2007 +minecraft12 +schatzilein +ficken2000 +staubsauger +spр“с’р’вђњр“вђљр’в¤tsommer22 +deutschland123 +0000-00-00 +vanessa2001 +wuschel123 +ficken1234 +fenerbahce1907 +arschgeige +schwesterherz +benjamin01 +9toheum8pw +leonie2005 +manuela123 +dicker1514 +monsterhunter +belafarinrod +dennis1997 +fcbayern1900 +panasonic1 +moonwalker99 +justin2003 +stefan1988 +strawberry +susanne123 +lincolnboy112 +pascal2001 +sommerzeit +podolski10 +philips123 +asdasdasd123 +sommer2008 +baumschule +123456qwertz +leckmich123 +susilein25 +147896352bb +burgerking +maureen123 +mileycyrus +newsletter +sommer2018 +herbert123 +peterlustig +husky610te +schlampe86 +revolution +africa1996 +qwertz12345 +elefant123 +bastian123 +fortuna1895 +schneeflocke +ichbingeil +moritz2000 +magdalena1 +schildkroete +oktoberfest11 +konstantin1- +anna-maria +depechemode +blondie555 +motorolav3 +komltptfcorp +michaela123 +asdasdasd1 +nuttertools +homersimpson +rxxzwvmqpl +friedrich1 +wasserball +sheepworld +denis898!! +klopapier1 +sparkasse1 +guildwars2 +waldkirch1 +killerpilze +marcel1997 +kornnatter +password1123 +engins1488 +constantin +wassermann1 +sonnenblume123 +nightwish1 +kaffeepot02 +dragonfire +ficken0011 +christian2 +braunschweig +metal4ever +frechdachs +christian12 +counter123 +mausilein44s23 +19archimedes87 +wurstwasser +5555555555 +leonie2001 +zwvxbhkd8d +1235755279 +zbql2wfrn9pkmehegqeknagni +corradovr6 +because123 +uqa9ebw445 +nutella123 +deutschland88 +prometheus +mercedes300te +podolski20 +nullacht15 +undertaker12 +wavemaster +winter2009 +zuckerwatte +calvin1999 +qwerty1234 +dominik1993 +handball11 +vergessen123 +sommerferien +schnullerbacke +blackberry +sommer2007 +apfelsaft1 +firefighter +accentmisk123 +kaninchen1 +target123! +sterntaler +bladerunner +fenster123 +silverstar +redbull123 +qwerty123456 +3141592654 +masterluki123 +speedfight +besiktas1903 +daywalker1 +annkathrin +sonnensche +muckelchen +sommer2013 +kuscheltier +linkedin123 +rubinrot99 +ferrari123 +freestyler +basketball1 +blumenkind +speedfight2 +sommer2011 +penispenis +x4ivyga51f +krogmann123 +stefan1983 +zitrone123 +hamster134 +123azer123 +gartenzwerg +9fbp7gvriihrwja5mx6a6tfbu +abc123abc123 +kerstin123 +werder2004 +12345678912345 +nasenbaer1 +pennywise1 +password!! +moskau1234 +nirvana123 +lokalisten +gwerty123123 +hubbabubba +fabian2000 +christian1998 +vonhinten2 +17cobaman73 +naibaf0902 +delpiero10 +kostenlos1 +commander1 +hidu123018 +aassdd11.. +stuttgart1893 +schnecke123 +eierkuchen +southpark123 +qweasdyxc1 +pascal1995 +kruemel123 +mummyshake447 +bielefeld1 +hallodu123 +zuckermaus +nicohaller00 +cassandra1 +suckmydick +dominik1997 +hellfire18 +meinschatz1 +pascal1234 +askimaskim +dominik1998 +gartenzaun +alicecullen +shithappens +schokolade123 +roflcopter +tocotronic +puschel123 +vergessen! +miezekatze +jennifer123 +flloaufklo2 +lalokill12 +marvingta1 +tobi123678 +justin2001 +shippuuden +fahrschule +daniel1997 +windows123 +mitsubishi +lok1966vfb +qwertzuiop123 +rockymax91 +skyler2000 +123456654321 +dalmatiner +corradog60 +fxqvgms8jawsktj9cxhbcnnfl +karl-heinz +peterpeter +9cwhxwy2x6 +justin2002 +maximilian! +123456789s +vflbochum1848 +gamemaster +myspace.co +cleopatra1 +anneliese1 +porsche944 +sophie2002 +hildegard1 +warsteiner +sebastian13 +fordfiesta +alexander14 +jackrussel +asdfasdf123 +tischtennis1 +edding3000 +qwertz123456 +transformers +123qwe456rtz +kick749flat284 +4vgyvq46aj +opelastra1 +butterfly1998 +william123 +starwars11 +hannover96! +osterhase1 +zimmermann1 +daniel1984 +pvtagent25 +schnecke12 +hollywood1 +billabong95 +motorsport +sebastian01 +bockwurst1 +adrian2000 +hanswurst123 +steppenwolf +yxcasdqwe123 +butterkeks +schillerwein1 +blackangel +ichhasse11 +sheinshein +knockknock +1234509876 +trustnoone +winter2010 +superstar1 +dortmund123 +grashalm67 +r192837465r +motherfucker1 +summerof69 +needforspeed +rammstein0 +weissnicht +bayern1900 +target123123 +lucien2007 +feebee01xd +daniel1990 +benjamin16 +sonyericsson +gameover17 +losangeles +kinderzimmer +gangster123 +logitech12 +rf66636663 +passwort0815 +marcel1998 +klopfer123 +leonie2002 +ichliebedich11 +1qay2wsx123 +gangster789 +passwort01 +hoppelhase +benjamin12 +monalisa9935.! +lukas12345 +blackmetal +delphi22cc +lollipop123 +sonn3nschein +wasserflasche +hauptschule +wilma123456 +fcbayern123 +florian12345 +lichtenrade163 +schnulli11 +47lhetrvwqtafxizmkphtm5jg +airbusa380 +marvin2003 +fbsjmm4hcxwbcoigqkybjjfa0 +alexander2 +ngc31aj5m1 +asdf11..77 +123456789aa +andromeda1 +gangster12 +internet11 +zonguldak67 +fabian1994 +chewbacca1 +teg4ka1p5u +michelle12 +nichtvergessen +blumentopf1 +thailand234 +silversurfer +slipknot123 +belinea123 +fischer123 +wesel.4800 +patrick1992 +enemykemal02072006 +southpark1 +stargatesg1 +mevhibe112 +benjamin22 +badewanne123 +wuppertal44 +fabiolucks2306 +a1s2d3f4g5 +clatronic95 +stephan123 +silenthill +martin1985 +xxxxxxxxxx +justin1234 +battlefront2 +dickerchen +a1s2d3f4g5h6 +taishan2011 +maximilian11 +wasgehtab1 +1qa2ws3ed4rf +chatbooks1 +michelle11 +abcdefghij +1357924680 +underground1 +madeleine1 +qwertzuio1 +leffe2001pb +dylancole1! +abrakadabra +sebastian2 +mercedes123 +nana123456 +voghzdg518 +niklas2208 +innovation +orangensaft +streber215 +original75 +mustermann +gaylord123 +celina2001 +kaiserslautern +werderbremen1 +fallenangel +dragonballz +johanna123 +alexander1983 +3doorsdown +hodensack1 +4&use24hyyj +tankstelle +depeche101 +soulreaver +ostfriesland +waswaswas123 +hd764nw5d7e1vb1 +superduper +thomas1988* +leavemealone +feuerzeug01 +redwarbike +cupcakecult1 +ichunddu123 +fordmondeo +harz4flirt +gnusgnas1809 +derdiedas1 +derdiedas123 +daniel1986 +poseidon123 +12345hallo +maxundmoritz +aurinsolo12 +ichbindoof +chester123 +iloveme123 +adolfhitler +3x4dc8ylpz +cannondale +schadowman27 +wurstsalat +tintenherz +1nawe25hyyj +bettina123 +apfelkuchen1 +jacqueline1 +goodboy123 +disturbed1 +gothic1234 +mercedes220 +flensburg88! +meinschatz10 +lolrofl123 +nicojene19061998mm +falle62860 +regenschirm +corinna123 +patrick1998 +dennis1998 +samydeluxe +fussball99 +handball13 +justin1999 +airut21airut21 +123456a123 +jomolitor1 +formentera +sportscheck +daniel1994 +destroyer1 +lol123lol123 +greifswald +kurtcobain +herthabsc1 +pinkfloyd84 +taschenrechner +c43qpul5rz +zag12wsx!! +pascal522004 +testtest123 +112233445566 +123456qwerty +deinemama123 +eintracht! +alessandra +fussballgott +arschloch6 +knutschkugel +passwort20 +hallo123456789 +zauberstab +r123456789 +trompete99 +logitechg15 +hannelore123 +nightmare1 +lagerregal +alexander13 +dortmund13 +techno1612 +schnuffi123 +badminton1 +grundschule +k0pfh0erer +illuminati +leckmich97 +vojvodic96 +evolution1 +2009-06-0115 +scfreiburg +donaldduck +fussball13 +19einheit89 +rubberduck +weintraube +hooligan88 +flower2own +goldfinger +aaaaaaaaaa +selti+1981 +solidsnake +fernseher1 +buddha2004 +shakespeare +schwertfisch +sugarsugar +griechenland +holzwurm12 +lucifer666 +breakdance +testklaus1 +julian2000 +peacemaker +halloleute +sternchen! +legolas123 +igelrosette12 +michelle13 +kaffeepot01 +engelchen123 +hardcore88 +starwars99 +2cxdn8s271 +bornamalian20 +password11 +natalie123 +lokstendal +jackdaniels +johnnylocke2 +dei3mutter +resident123 +frettchen1 +einstein123 +yuantuo2012 +2009-06-0118 +erzgebirge +eintracht123 +radeon-1973 +mackline12 +gartenhaus +residentevil +sebastian12 +martin1234 +psalm124k1 +anastasia1 +flocke2010 +justin2004 +zigarette5 +hfhkc99jk1tgfcodnkorll2wl +liebeskummer +heidemarie +porsche993 +bestefreunde +123aleram321 +hinrich252 +sebastian11 +feuerstein +hellokitty123 +moppelkotze +daniel2002 +gladbach11 +kackklo123 +blackdragon +sunshine11 +t11011989k +123park123 +silbermond1 +schnuffel123 +leonie2004 +alibaba123 +galatasaray1905 +allesmeins +skateboard123 +newzealand +drecksau23 +giuliana01 +marieluise +pauline123 +johannes270486 +bestfriend +12345abcde +masterchief +pustekuchen +iloveyou!! +hodenkrebs +mausefalle +finalfantasy7 +weltmeister +johnnydepp +robinhood1 +niklas2005 +qwerasdfyxcv +schwester1 +berlin1234 +hatebreed1 +tannenbaum7 +callofduty1 +london2012 +passwort13 +linkinpark15 +nemosexxx6 +dickerchen1 +wellensittich +lamborghini1 +sicherheit1 +lollollol1 +neuesleben +goldschatz +marseis1122 +narusegawa +ranzratte1337 +underground99 +klauspeter +nintendo3ds +alexander7 +bkzxpwqhph +matthias11 +steffi1990 +mercedes11 +sommer2014 +johannes1997 +handynummer +julchen123 +annalena12 +nintendo123 +busbahnhof +schaefer123 +qqolylya1998 +schwipschwap +dariamon21 +drafta1402 +cornelia08 +realmadrid1 +daniel2001 +gelsenkirchen +jennifer1991 +bienchen123 +margarete1 +johannes12 +fischfisch +nadine2000 +zwergnase7 +persilone1 +mollyfinni_1 +handysilove1 +teufelchen1 +dynamodresden +gigagames1 +lincolnboy11! +schneehase +lautsprecher +passwort12345 +mediamarkt +metallica123 +florian1997 +lauramika6 +benjamin11 +christian10 +ausbildung +dieliebe92 +niederlande +morrowind1 +handball.01 +justin2006 +99luftballons +drkiller2025 +arschlecken +baerch3n1762 +mama123456789 +borussia09! +1a2b3c4d5e6f +schnucki123 +krankenhaus +bliblablub +sandro2503 +daniel1988 +abendstern +dubistdoof +augustiner1 +19sandra76 +klausdieter +hamburger1 +download14 +passwort95 +tanika2008 +oak3xfuzv6g4b7bijtoy3c28q +wildekerle +taschenrec +helloagain +pallmall123 +weizenbier +schlange123 +wuppertal1 +meviscool2354! +kaffeepot04 +killer12345 +jamesbond1 +deutsch123 +marcel1999 +zaubermaus1 +hotzenplotz +bestellung +blubber123 +iloveyou12 +123456789p +nostale2010 +wasserturm +jennifer12 +schuelervz +chelsea123 +lottaanu12 +zuckerschnecke +fensterbank +friendster +skinhead69 +oliverjaeger1 +superman12 +schwertfisch11 +katharina123 +persephone +jackiechan +schnecke1996 +00schneider +panther123 +asdfedc123 +leonie2003 +freiheit12 +television +passwort007 +sonnenschein01 +ferdinand1 +skyline123 +fenerbahce1 +tombraider +bartsimpson +3d8cubaj2e +bildschirm +mercedess500 +hakunamatata +miankiller +waschmaschine +voltigieren +madadeda1997 +sanandreas1 +1234qwer123 +teddybaer1 +5zyismgneolnhv6tbopqn3fju +julian2004 +fickenficken +lol1234567 +a1b2c3d4e5f6 +atilla1998 +vermittler1 +mn17051980 +11127332316 +dejanbraun4 +miafoxlelvis1665f. +warmerpo98 +christian14 +bernddasbrot +ice04660466 +facebook123 +niklas2002 +deinemudda12 +bruderherz1 +august2008 +astalavista +passpass11 +loewenherz +schinken123 +steelseries +dennis2000 +alexander9 +deinemama1 +arschloch12 +niklas1995 +kaffeepot03 +freiheit89 +mausmaus123 +schalke0411 +cinderella1 +tischrock23 +michelle00 +handball12 +uvgx8f8232 +msvduisburg +babymelder04 +drachen123 +diana12345 +schalke04. +mg8xyqxrjk +pellepelle +electronic +spyderfrog12 +duschlampe +1224612246 +qasd19dodo +alfaromeo1 +hasnainraza +gandalf123 +opelcorsa1 +stefan1962 +winter2013 +passwort93 +spartan117 +sonnenschein12 +1996master +scheissegal +hallohallo! +magdeburg1 +johannes10 +moinmoin123 +kan0nenfutter +kuschelmaus +mercedesbenz +runesofmagic +lisamarie1 +slimshady1 +qw12345678 +weihnachtsmann +christian98 +tobias1993 +caprisonne +test123456 +prince3007 +christian01 +123456789049 +dobermann1 +calibra16v +sommer2005 +red24red24 +sonnenbrille +kingdomhearts +rebecca123 +telekom123 +13031980marc +niklas2001 +computer11 +gwendoline +kamehameha +vertrauen1 +dennis1991 +blitzkrieg +peugeot307 +katzenfutter +darksoldier +mallorca05 +leonwilken123 +apokalypse +sommer1946 +christine123 +nightwish654321 +sleipnir88 +wasserball18 +schokokeks +123selo123 +golfv5power +suppenhuhn +access2010 +hannover97 +chocolate1 +d9zufqd92n +sonnenschein61 +pippilotta +iceolator-007 +16juni1972 +brustbeutel42 +wonderland +boeser5438 +moritz2002 +element123 +bartagame1 +martini123 +123456789987654321 +capricorn2 +michael111 +hertha1892 +yannicmamma +florinator +padi24xmas +christian85 +bigbrother +hurrensohn +leverkusen123 +12345654321 +frikadelle +flocke2001 +qwe123qwe123 +p3rat54797 +salzgitter +iamthebest +konstantin1 +multimedia +sommerwind +peugeot106 +billabong1 +lussifer123 +linkedin2011 +taschenlampe +gutenmorgen +polarkreis +fisch1236987 +jessica2000 +sebastian95 +wasserwasser +xilef-9001 +erdbeere34 +julian1995 +milchschnitte +apfelbaum1 +matterhorn +niederrhein +behindert1 +alexander. +015115348502 +sparky2009 +finchen123 +freestyle1 +zeronotsukaima2 +marvin2001 +schwimmbad +pokerface1 +dennis1992 +finalfantasy13 +1954115fil! +schneider! +herthabscberlin +123pornoparty +d36883621z +rocknroll1 +111111111111 +music4life +lovenico82ns! +daniel1987 +sonnenuntergang +hellohello +halloduda1 +hausaufgabe +erdbeere123 +anmeldung1 +kathrin123 +7ygvbhu89ijn +brasilien1 +todesengel +ladykiller +berlin2009 +ichbins123 +michelle01 +meerschweinchen2 +rantanplan +freelancer +esslingen2010 +blackriver +jeffhardy1 +philbode98 +wonderwall +dennis1994 +frankenstein +horsthorst +dominic123 +seestern02 +escaflowne +moonlight1 +123456789q123 +dennis1234er +rudifink97 +sebastian96 +sommerurlaub +fortuna95! +xtseo2011tdx +gamer123124 +snowboard1 +kingdomhearts2 +fusselfan123! +nopassword1 +taschentuch1 +31397tobias +motherfucker123 +askim1997! +kleiner123 +checker123 +marcel1993 +jackson123 +natalijayior7j +leonie2006 +strolch775 +k01903435a +ybl82n4qw7 +fcbayern12 +kaufrausch +daniel2008 +xxlolxx123 +abc123456789 +leopard2a6 +benjamin87 +jennymaus1 +maus99130200 +arschfick1 +benjamin18 +lollypop12 +blumenkohl +entenhausen +sipotech123 +evanharvey +taekwondo1 +rammstein123 +danija2009 +seepferdchen +arschloch. +1q2w3e4r5t6z7u +johannes98 +ichliebeihn +nerone2000 +stachiundkotze +1020304050 +a1234567890 +independent +bollywood123 +ichbindumm +niklas1999 +klausklaus +schatz1234 +engelchen2 +sackgesicht +robin12345 +computer88 +bvb09kevin +harrypotter1 +football123 +emmawatson +bommel2009 +nachtigall +norman12346 +social212224 +pittiplatsch +bleistift1 +schnappi123 +1qwerty123 +wohnzimmer +logitech88 +tomtim1961 +alansucks7 +packardbell +newthree51 +benedikt16 +rademacher +nathalie123 +stefan1991 +fruchtzwerg +swordfish2 +maurice123 +i5fisw4&c8n +dieter1951 +constantin1 +backofen22 +stralsund1 +syncmaster +sonnenschein! +pascal2000 +moppelchen +u17011955m +tobias1995 +gangbang123 +moinsen123 +password69 +dashausanubis +ichwerdedich +schroeder1 +florian2002 +deutschlan +killer1994 +michelle1997 +schwarz123 +alexander88 +kleinehexe +torsten1983 +garfield01 +qdujvyg5sxa +123456789o +hummel1908 +marcel6666 +hescheld31 +bastian0307 +jasmin2001 +beatsteaks +salamipizza +doncamillo +ichbinich82! +wutangclan +asdf123456 +battlefront +winkelmann +polizei123 +badewanne12 +schnattchen +freitagder13 +wolfgang55 +nvtcl57qptms +t5r4e3w2q1 +florian2001 +justin1998 +blumenvase +fabian1995 +katerfelix +rockabilly +marvintim18 +paulchen123 +mamamia123 +medicopter117 +paintball1 +pzxei0oj16 +alexander5 +rosenstolz21 +lennart123 +maennlein99 +marcel1988 +roger260376 +sicherheit-2017 +urlaub2009 +computer01 +schnuffel2 +ballack123 +mannheim123 +martina123 +elster18de +transporter +whatthefuck +penispumpe +yashamaru1 +rollerball1 +hundekuchen1 +niklas2008 +bg21121946 +alexstam08 +angelika12 +sasukeuchiha +jackass123 +keinplan500 +steinbock1 +amadeus123 +123hallo123 +killerwolf95 +marcelg456789 +wasserfall1 +startrek11 +realschule1 +michelle2000 +uncharted3dd +julian1998 +cristiano7 +markus2009 +christoph123 +windowsseven2010 +skate4life +skyliner34 +marcel1983 +jo94hann13 +chrissi123 +1478523691 +lillysophie10 +porsche911! +mx.poppen.de +florian1995 +nickelback +passwort10 +msikkp1982 +porschegt3 +wega08-08wega08-08 +hannover123 +literaturfreak +qzgnl6m2ff +finalking09 +systemofadown +etienne123 +unantastbar81 +kleeblatt123 +justin0211 +sternchen13 +celina2000 +3drillinge +maradona10 +schweizer1 +na7722424dine +1899hoffenheim +tittenfick +manfred123 +kaffeetasse +bademeiste +1a2a3a4a5a +christian11 +computer13 +schwein123 +deutsch2006 +devilmaycry +yxcsdfert123 +festplatte +administrator +mybabyboy1 +linkenpark +sebastian9 +nicojuni97 +honigkuchen +punkrock77 +kugelschreiber +firefucker +fireball919 +haftbefehl187 +billclinton +karsten2310 +supervisor +fischkopf1 +nicole1982 +zungenbrecher11 +pornopilot13 +ferdi501501 +baeckerei1 +wasgeht123 +christian88 +business11? +warriorcats +topm3fae5qw20w8xvn5ktfdtk +silvercrest81 +arschloch11 +krankenschwester +stefan6464 +marcel1992 +bremerhaven +lm25061993 +telekom223 +kleinermann +billytalent +devilmaycry4 +forelle63.- +alina123456 +b849ddkpyt +wwwwwwwwww +luzifer666 +christophe +motormotorrex +wildschwein1 +schneckchen +australia1 +asdfghjkloa +apocalypse +eric10031998 +sarah12353 +werkenntwen +schnuller1 +silberpfeil +pechgehabt +rittersport +nicolas123 +arschloch9 +andrea1984 +meinereiner +moritz2010 +hellokitty09 +sonnenkind +fabian2006 +qwer123453 +kingbushido +serifebez0 +edding8400 +arthur1992 +neukirchen +marcel1990 +heinrich123 +an051196ko +schokokuchen +berlin2010 +michelle2006 +nebelhorn11 +chaosfloh44 +engelchen! +charly2021 +hampelmann +db.e.lok151 +betzendorf1 +discopogo1 +garfield123 +mysterio619 +hannahmontana +niklas2000 +scorpion123 +salamander1 +kamikaze123 +wurstbrot1 +vanessa2000 +wochenende +southpark9785 +racernikafaca123 +deinemudder1 +balotelli45 +pswrialein +1234567810 +diesims123 +20suzuki12 +philipp2001 +1qayxsw23edc +mikefaltins32 +november99 +startfinding +uzumymw123 +linkinpark11 +philipp1996 +murcielago +mercedes13 +residentevil1 +superman11 +16sweetie08 +petersburg +sofakissen +warhammer123 +lukaslukas +monique123 +dragonlord +marvin2000 +callofduty6 +logitechg11 +ichliebedichx33 +transport2010 +schnecke13 +pander1999 +chevelless70 +mamasnummer1 +passwort99 +schluessel +cool123456 +edaforum07 +robinlulu123 +antonia123 +123456789t +sonnenschein15 +marlon2000 +dennis1995 +sebastian88 +rammstein12 +gangster-10 +dorothea1970 +eileen2012 +angelika123 +residentevil4 +tiantianaini1 +marcel1987 +tugbahatice +windows2005 +tomojapa45314531. +wzkk2pmccf +ichliebdich +joshua2008 +italia2006 +dortmund09! +affe123456 +arschficken +junimond72 +basilman123 +goldsam2011 +screenfun1 +spiderman3 +dennynico1 +lionkingselby1 +passwort22 +bitburger1 +falkenauge +lotusnotes +thomas2550 +kalender01 +herzblatt24 +jean-luc98 +schweden04 +tischtennis123 +vandervaart +niklas2004 +marcel12345 +dragoonkiller1 +michelle96 +el2yo1s7inc93 +johannes2001 +thunderbird +hallo1980da +12345612345 +ghetto0815 +diabolo123 +chickenwings123 +quake3arena +andreas1980 +kundencenter +erdkreifer123 +satanas666 +mullemaus1 +kamasutra1 +bvb11031997 +mamaistreich123 +johannes87 +passwort66 +deskjet670 +minecraft2 +spamweg147 +stuttgart21 +robert1234 +1998melodie +pinkpanther +sniper5943 +123456789g +adolfhitler88 +dortmund11 +joki220485754 +untergrund +brutscher!dbre +adminadmin +johannes11 +bleialf2010 +pascal2010 +fcbayern45- +amerika123 +mamaundpapa1 +charlotte123 +mariechen1 +christophe82! +schleicher +freunde100 +135hc915bkvz +tigerkatze +merlin1996 +02091996sp +17.cobaman.73 +marius123a +stargate85 +ronaldo007 +playboybunny +snickers30 +alfaromeo156 +29huenni10 +gamestar65 +stefan1234 +tobias1996 +lufthansa1 +drachenherz +wirtschaft +vanessa1994 +mariogomez +liebeslied +passwort666 +celine2005 +lennox4717 +yannick123 +scheisse12 +schreiner1 +leverkusen1904 +belmondo89 +trackmania +sparschwein02 +ficken3000 +tester4711 +alexander07 +mareike123 +wunderland +fabian0711 +michelle02 +blaze10+1989 +paulchen01 +anikan1978 +36chambers +steven22121997 +qwertasdfg +markus1973 +schule1998 +pekingente2016 +potter4711 +hansjuergen +somaya2014 +qaywsxedc! +leonie2007 +wishmaster +gaziantep27 +mittelfeld +fischkopp1 +8d9f38251c +softfrei1! +josielynn2006 +thomas1984 +maulwurf123 +mickymaus1971 +fabienne01 +ann-katrin +sfai4hnapy +chayenne07 +123456789l +azertyuiop +mariemarie +ingolstadt +stefan1971 +sebastian18 +230399justin22 +majestic12 +raphael410 +1ilona2anna +kr3ative1& +napoleon85 +basketball2 +patrick12345 +hochzeit2013 +applejuice +lionelmessi10 +ichbingott +augustiner +klausimausi +1357913579 +august1974 +schneemann1 +zauberwald +mariemike2 +notrequired +asdfgh1234 +americano91 +dachdecker7 +warcraft12 +chantal1998 +weihnachten1 +20leckmich04 +erzieherin +wolle1481989 +halloechen +bienemaja1 +michael100 +klappstuhl +janosch123 +michi134625 +weltuntergang +hsvhamburg +thorshammer +facebook12 +philipp1995 +12345678aq +florian131098 +missmarple +bernadette +remix4ever +isabell123 +minecraftserver +marlon2008 +dornfelder +justin2000 +kakstreigen9 +florentina +forever123 +paanybocom +minecraft14 +12345678900 +grabich1988+ +bluemchen1 +onkelz1980 +becky231104 +2222222222 +1q2w3e4r5t6z7u8i +herkules79 +nailimixam +almantas2012 +933596kiwi +wasserbett +urlaub2611 +hausjacke07 +xsazjft949 +supergirl1 +1d2u3w4e5r6t +gitarre24061995 +ronaldinho1 +daniel2004 +polopolo09 +bohnenland3 +melitta0604 +kindergaudi +qaywsx1234 +31malchin71 +alucard666 +nadine1234 +stuhlkrebs +blumentopf123 +wolfsburg1 +8891sucram +griechenland1 +ilovejesus +zickezacke +drachenfeuer +speedy1996 +passwort07 +passwort9999 +holopainen12 +thomas1234 +washington +logitech11 +kackarsch02 +daniel+2004 +dresden123 +julian2001 +turntable1 +hujdodupy1 +cassiopeia +pathfinder +apelhans12 +sonnenschein11 +mamapapa1234 +hellsing123 +hanspeter1 +arsenal123 +erna171024 +mariejo1609 +pitbull0504 +trampling12 +infoinfo98 +katzenjammer +newyorkcity +hausnummer +sophia1999 +swordfisch +butterblume1 +international +1q2w3e4r5t1 +vspd001ars +giuseppina +left4dead2 +callofduty2 +mm27081997 +theredswordman1 +oberhausen +x4yam3dfsg +eisernunion +fingernagel +handball92 +modellbahn +hansjoachim +sportfreunde +k123456789 +dominique1 +mama123123 +pummelchen +1q2w3e4r5t6y7u +gladbach1900 +josephine01 +02doris1954 +westberlin +nokiaphone +haschisch1 +gissmosammy1 +feuerwehr112! +charmed123 +*!wallu85*! +arschficker +papagei123 +gerolsteiner +roadrunner7 +godzilla123 +gymnasium1 +edwardcullen +hartmann98 +milcheis123 +pinacolada +natascha123 +vincent123 +volleyball1 +dortmund1! +phantom123 +manuel2020 +barbarossa +micheldi11 +calibra123 +lindenberg +adlerhorst +newholland +christian91 +465197332a +rebstock01 +keineahnung123 +liakiki.01 +fussball1! +stronghold2 +cocacola10 +christiane1 +justin-1004-m +sommersonne +cheyenne123 +ktg2bmncfx9n +sascha1991 +jennifer98 +stinkhals1 +rosemarie1 +hercules123 +nicolas1998 +2211manuel +yannick1994 +hundewelpen +saturn1998 +werdenfels25 +vollpfosten +pallmall01 +dietrich1307 +2100211231 +geierhorst66 +jshoya28127 +wert123453 +oberstdorf +panzer_1488 +impossible +samuel2005 +2pacshakur +slipknot257 +blutengel13 +martin1986 +lichtenberg +internet09 +sternschnuppe1 +dragon1234 +mercedes320 +gabriele63 +hugendubel +thomas26371 +analritter +baumhaus123 +sinasauto1+ +florian1234 +selina2002 +plener060908 +winter1989 +strenggeheim +ipodlars13 +nokianokia +blackpearl +gpcktdp3dk +carpediem1 +nadine1989 +sophia2010 +heilhitler88 +caroline123 +haftbefehl +kugelschreiber1 +123456789e +schule12345 +hundefreund +nightfall1 +bundesliga1 +opelcorsa1104 +sachsenhausen +geheim1996 +579537marc +alexander87 +pepebene22 +f1e2l3i4x5 +bratwurst3 +colaflasche1 +holgerson95 +meerjungfrau +feuerzeug03 +gardasee321 +wertfrei1! +aleksandra +liza20laria +fabian12345 +seniseviyorum +sebastian91 +1212312121 +alexander8 +benedikt11 +rauchen2410 +nikolai123 +volkswagen1 +friends4ever +himmelblau7 +clau12di08a +marquaru1a +maximilian2 +nickelback00 +thomas1989 +karlsruhe1 +ichbintoll +moritz1234 +tobias21601 +22re07na76te +osnabrueck +handball1! +44309dtmd12 +passwort88 +corvettec5 +12345panne +katharina2 +alisha91212 +passwort2001 +merlin2001 +asterix123 +bubblegum1 +aileen2001 +pusteblume1 +marvin2005 +gar1953ten +reissdorf1 +letmein123 +123456789d +stadtwerke +novosibirsk +werderbremen123 +hauptstrasse1 +hannah2002 +michelle97 +helmut1983 +erencelik12 +pascal1996 +aragorn123 +ferrarif50 +julian1999 +radieschen +kopernikus +markus1995 +laurasophie +hornschwanz1 +tsvammertsweiler +kawasaki123 +sofia22092008 +sternenstaub +firestorm11 +fightclub1 +goldhamster +dirtydancing +logitech91 +svenmao2004 +christian82 +lolligirl000 +berlin2013 +herbert1940 +3usvjl6x1r +berlin2020* +woodstock69 +elfenlied1 +patricia123 +shelby-gt500 +neblenz1959 +peugeot306 +jonathan123 +baerchen1! +moritz2008 +565491d704013245 +kreuzberg36 +festplatte02 +selina2003 +germania12 +maximilian10 +melina2006 +schalke04123 +s2x7b3l5z22 +thomas3299 +annette123 +19112000ek +carpediem1! +da13brue96 +0190123456 +phoenix123 +cocacola12 +iloveinter10 +g7c78906g7c +nudelsalat +sandra1974 +master2525 +0.00000000 +chromati88374 +dv235003535 +norbert123 +kackbratze +franzi1111 +sebastian8 +werdien1988 +tischdecke +qyzbrt1996 +oventrup19 +livestream +lor33bomam +rosengarten +07091983rrr +uirsbqlnvn71yj7b3jgmqnd0d +dermeister +ficken123! +marvin200490 +walter954017 +informatik +susa1198.x +bingobongo +012345678910 +bowling300 +lippenstift +31121931bummi +linkmaster +babymelder03 +illegal2001 +michael1976 +andreas132 +alexander95 +fleckchen1 +dm04071975 +110882h32214 +vandervaart23 +reisereise +62843951hans +gurkensalat +achdukacke +f1f2f3f4f5 +hubschrauber +valentino96 +satansday666 +findus1994 +24allgaeu24 +tobias1234 +mitternacht +charmant12 +fhgjkgkgjgh +eddy30101968 +sommer2004 +bestfriends3 +palmolive1 +nicov8gamer +westermann +30media123 +06.07.1981 +zahnspange +sp345399291 +community1 +patrick100 +lifebook1971 +schlagzeug12 +starwars23 +antihero123 +frankfurt420 +hannes1802 +alexander2000 +abitur2006 +lena-marie +fragezeichen +angelika55 +master2004 +schneider01 +hamburgersv1887 +kawasaki11 +uschimuschi +123456789w +scooby1999 +schumacher1 +lust17schwanger +neckermann +ersguterjunge123 +leonpascal +michelle03 +einmaleins +benjamin77 +daniel6378 +qwert54321 +mariokart1 +sandkasten +reaper1969 +bl00dy1986!!! +22041997th2011 +2009-06-0120 +hermann123 +weinachten +freundex15 +skywalker2 +bernhard123 +dasstimmt88 +lisa-loreen +sophia1997 +yxcvbnmyxcvbnm +schweinebacke +jennifer96 +5c67197e282c48f6 +lmjmemrm714 +thomas1966 +schmetterling1 +a12345678a +michelle2001 +jaegermeister +pommes1995 +mickiemouse1996 +flo11flo11 +porsche924 +gitarre123 +nicole2002 +arschloch1! +handball22 +sagmeister1 +jennifer87 +xander3004 +chalomo123 +tftsetup75 +supernova1 +alexandros +kombasamv3 +hanswurst! +cheesecake +leandro2003 +aggr0berl1n +mutterficker +ziegenstall +schalke041 +dennis98789 +spiderman2 +aminakoyim +chiller1234 +christian13 +einstein497 +angelclub9 +hundesport +badcompany +schmidt123 +manuel1997 +skorpion14 +juergen123 +slawa300382 +leonardo123 +leonie9513 +fcbayern99 +braveheart +schulferien +never4ever +mackenzie1 +kappeln1982 +desertstorm +hatschibertr1 +scheise123 +08150815aa +hammerstein +radeon1973 +001muethai +brille1234 +sonnesonne +christophe82 +fireblade900 +tatjana123 +jugoking20 +hokuspokus +hallofantanas28 +jochen1957 +daniel1302 +roemer-herbert +delfin1234 +gammler2106 +meinekids4 +hasenstall +muhammed12 +ichunddu12 +towntycoon +23121983bm +cheesburger +avrillavigne +mitsubishie10 +vornamexd1 +florian2005 +farinurlaub +kanalratte +butterfly8 +2ivjlzawpscbmawn1mxcutrgn +apfelbaum123 +markus1999 +michi_der_86 +zwilling22 +sebastian93 +emiliesteger +daniel1992 +patrick1997 +sonnyblack +hammer1234 +ameland2010 +julian2010 +tobias1998 +achtzehn18 +leonjason1 +selina1996 +computer321 +qawsedrftg +boxinger35 +andreas1990 +1337tiffany! +butterkeks1 +ypqcs2aeba +festplatte07 +schnuffel112! +mokkakuchen7 +marion1959 +annalena08 +test123456789 +gott3brian +sonnenblume11 +karo63lczak +159753159753 +dennis1990 +terminator2 +liselotte1 +hanninanni +cvzefh1gkc +knightrider +1q2w3e4r5t123 +madagaskar +pascal1997 +clannoname +123abc123abc +alienguise3- +miha123vta +alohomora1 +akumroland1980 +energie123 +christina! +angelika93 +stefanie21 +aceracer10 +1234567812345 +motocross18 +milomoje12 +smoerni1996 +berlin2011 +haltdiefresse +thunder1712 +leonard14351 +niklas1997 +iphone2011 +baumeister +rattenschwanz +dennizze93 +kindergarten1 +franzi2009 +sexyberry02 +kai19981998 +ichliebees +eins1zwei2 +philipplahm +ellipirelli +123123123! +gettysburg +archimedes +master1992 +mittermeier +generation +chantal123 +antonia1512 +holger2580 +minecraft01 +freeway147 +ficken1985 +supernatural +fabian1999 +pep4teens1 +fuckoff484 +eliasjoel1309 +daniel1982 +hooligan123 +werder1234 +greenpeace +thillyane1 +mar0000lene +kevin12345 +123qweasdy +83558355nn +turnschuh1 +motorsport1 +1passwort1 +chris-17072017 +marianne123 +abcdefgh123 +jvds48ws79 +sandra1978 +ronaldinho123 +wb9t7jf9fe +hondacbr600 +cpxhgt54646 +niklas2008*. +herkules123 +200262armin +diamonddash1995 +animaljam1 +hohenstein +feuerwehrarmy112 +archie2009 +dsacslk2f2 +kilian2002 +hamster666 +flasche007 +al2720bert +support123 +691b4dd30ff92343 +ichwillrein +wildarms72 +warcraft321 +vanessa1234 +1234567asd +johanniter +berlinberlin01 +neuespasswort +starwars4ever +motdepasse +dennis2001 +carpediem89 +hurensoh19 +kugelblitz +masterblaster +xbox3602011 +herbramwald +jesus4ever +mathematik1 +leonie2011 +vanessa2007 +d197346825 +homeboy1004 +fordescort +karsten1997 +waldemar123 +val353nxhc! +centurion1 +sascha1981 +sagichdirnicht +frankfurt123 +ruamzuzla1 +motherfucker2 +winter2007 +honigbiene +glorreiche7 +moritz1997 +4973849738ha +sommertraum +julian2005 +24012001lisa +goerlitz2979 +mittwoch2701 +268105689a +tr0310pp0508 +hanswerner +digital123 +weingarten +123abc456def +skorpion11 +hamburg2012 +ilovemusic +vanessa1997 +marius1996 +winter2006 +michelle15 +salementhrex +hellraiser +dudua71278 +rambazamba +snickers30% +poiuztxt31 +justinbieber1 +12345678ua +3715529sara +wimmer1234 +ingocathy13 +duv386058maj +michi12345 +philipp1997 +8862388623d +drache1234 +celinejustin +bella08092010 +audia6avant +scharnagl1 +katze12345 +daniel-gawlikk +jennifer20 +schatz1990 +jugendzentrum12 +niklas3011 +m123456789a +abcdee12345& +elvis1681977 +williwilli +xxzhmkcqnk +stockfisch +nadjeschda +thomas1980 +maus08101992 +batistabomb +fw22121995 +300680b71319 +asdfghjk12 +casocaso#1! +jackundernus96 +pinguin123 +03215728ss. +budspencer +feuerteufel +kkkkkkkkkk +michelle10 +digimon123 +romania2020 +meierlingxyz5 +weltmeister2016 +lizalaria20 +henkat020981 +allesklar1 +josefine93 +dezember88 +katharina! +weisserhai88 +stronghold1 +bundeswehr12ad +pascal1992 +1234qwerasdfyxcv +pfadfinder +thomas1988 +zuckerstrassen +ausbildung1 +warwick1988 +gummersbach +jennifer@1996+ +deinemutta +rammstein2 +vampirediaries +drachenreiter +butterkuchen +internet08 +em67769809 +killer1993 +freiwild123 +double2004 +gangster88 +startseite +evolutio12 +nitewhisper1 +grancanaria +hkdjayyksa1 +dragon1988 +raufaser52 +nathalie14 +sinned1996 +heike_boehm +schwimmen! +gwerty123! +hn7xf8bxfn +axel9250495 +sonnenschein2 +cde3456tfc +blacktiger +michelle18 +qf8g9zxwhp +1telefon23 +zigaretten +kirschkuchen01 +cologne123 +pillepalle1 +hppavilion +30webkatal +sukram1470 +marsupilami +yamahayzfr1 +celina1998 +daniel1989 +k5sdjsg4ab +kemler2010 +thisisforuncreatedsonopw +alicia1998 +sonnenblume12 +hotmail123 +minimaus2002 +elisabeth! +kerstin2010 +tagebuch02 +niklas1998 +motocross1 +patrickstar +kitty12345 +easyrider1 +walfisch98 +jackdaniels1 +tomatensalat +littlefoot +loerrach12 +paulinchen1 +daniel1516 +pornostar1 +maximilian9 +stroonger91 +hannah2000 +11ronald69 +alex1234alex15 +alice311951 +mausimausi +november11 +cannabis123 +casablanca1 +vou.585756! +monstermash +spike123456 +kirschbaum +s0nnenschein +caroline89 +hs02121957 +dn66ie3asd +megaman123 +zwickau2304 +maik30111995 +marciman12 +keinbh3091 +dumpfbacke +wolffinchen1 +kellerkind +eintracht67 +runescape1 +230775schl +silvester123 +halloballo +goldrausch +metropolis +poiuztrewq +andreas2007 +marmelade1 +themichi07 +pedro12345 +hoppel2008 +bernhard01 +deutschland! +1arschloch +harrypotter123 +gangsta123 +stierhoden +herzblatt1 +celina2007 +modernwarfare2 +kreuzritter +ekrankjala852 +ichliebedich12 +malsch1998 +5991198bo! +monitor123 +tagebuch03 +penner1000 +17042010aa! +paulchen12 +aleksandar +christian27 +damina1997 +flokiller007 +willkommen1 +fuckoff123 +handball07 +mozart2013 +warcraft95 +kackwurst1 +dustin2002 +klipitz-thoerl +mustang123 +blumenhaus +123456789lol +neumann1230 +bremen2004 +brieftaube +darklord1990! +gaertner67 +canimbenim +moritz2005 +nissanskyline +internet07 +alexanderbier2008 +peter12345 +huntelaar25 +passwort23 +darkanubis1 +handball08 +kennwort1234 +unionberlin +cheguevara +seline2508 +dudenhofen +werner1990 +schweinehund +daniel2003 +hallo12311 +schlumpf123 +patrick1995 +1234567890q +september1 +schnecke01 +carsten123 +patrick1996 +armstrong1 +x166x666x66 +1337tiffy! +haus-maus06 +rhodos1974 +scheisse11 +saftapfel1 +biggiengel1 +fridolin01 +swordfish71 +reiterhof2 +rockstar88 +xxxyyyzzz111 +thomas1990 +ncc1701d312 +bushido1995 +hustensaft +faaizkhan12 +h8bxfztxfn +hackfresse +sandra1988 +passwort1! +maximilian96 +midway6072 +niallhoran +gta45gta45 +kartoffel123 +kaufmann123 +26021971ss +francesco1 +maumau3105 +prinzessin123 +feuerzeug05 +warhammer40 +rapper_chris +juliana123 +siedler123 +radsport61 +fliesenleger +thomas1981 +qwertyuiop123 +basketball23 +lumpiking98 +iceicebaby +binakular1 +chinchilla1 +cappuccino +knoepfchen +passwort91 +trowssap123 +sagichnicht1 +enterprise1 +target123!! +schnuffel01 +technik123 +julienne10 +mayaluna332 +evangelion01 +uncharted2 +metallica81 +starwars1218 +bommelchen +123456789h +sackratte1 +malgorzata +fussball95 +suedafrika +kinderriegel +perkele14! +tobias8066 +slavonskibroad +callboy1982 +delmenhorst +fernseher123 +botpregg67 +lpz93ssskqw8q1 +carrera911 +kalle20011996 +vampirella +partypoker +passwort00 +maxmaxmax1 +jendouba10 +conny19981 +chatbooks123 +starwars10 +warcraft11 +hundeleine +blumenzauber7 +skywalker99 +romania2017 +feierabend +julian2804 +30rchpaway +12345sechs +nokia6230i +hannah1807 +naschkatze +daviddulle2 +wasserhahn +fw12071990 +timstagg97 +tomdiemaus2 +mariko2005 +jkflqg5yrbn4eeormlc3mi1lq +lianchen007 +waschlappen +trockenbau +1234eckstein +glorfindel +vvs2s4uvvs +quartett11 +bud&hill12 +ehurslrak1 +schalke.04 +twilight114 +destruction +oldenburg1 +schutzengel1 +stefanie123 +manuel1998 +carolin123 +8765432150 +steinberg1 +kasia380855 +hundertwasser +hoppel7993 +freestyle! +mostwanted1 +1schroeder +pas1stella2 +ni27co02le +pokemon111 +florian1991 +michelle2002 +trommer123 +freundschaft1 +fussball01 +mausebacke +grinsekatze +pokemon12345 +christian96 +porsche928 +123456789jan +zwillinge2 +florian1993 +velociraptor +winter2004 +peppy123123 +gesamtschule +voodoo7713 +michaeljackson +porsche996 +asdfasdf12 +joni123456 +stefanie09 +spielen123 +halloween1 +u93kbosdzg +jennymarie1005 +ficken1000 +lara-sophie +regenbogen13 +schnutt1958 +martin1990 +traumfrau1 +paulchen99 +daniel1985 +kallowski99 +evangelion1 +chaheincil +trustno150 +pepper0808 +evolution6 +sascha1973 +yobmaess123 +delta-1314 +spellforce2 +kaesekuchen +groundzero1 +angelina21 +bayern4ever + +interceptor +alexeliza-1973 +tugbahatice0707 +katzenklo123 +amsterdam1 +schweden12 +eqforever09 +erfolgreich +august2006 +reinigung72 +manuela2006 +10021972tr +17031997abc +tt030a_2608 +beckenbauer +mama123456 +jannis2001 +raphael123 +lamborgini +linuxuser1 +honeybunny +mich15sch08 +allahakbar +kleiderschrank +yvonne0102 +mateusz2007 +wer-ist-cool +duhurensohn +yj2vwbtdr6 +fabirehm12 +kaffee1pille +liebficken +sonnenschein7 +alexander80 +schn10ucki +christian5 +millencolin +121074s70610 +seitensprung +cviaeq7ght17snoulni1chdsl +mp3ddjoko4 +ichbingeil1 +pimmelkopf +5dessauer6 +chiara-654 +2raumwohnung +napoleon123 +alexander1990 +painkiller1 +spidermann +schatzi4338 +meister2009 +leverkusen1 +lmjmemrm714! +76steffi72 +schnuffi88 +angela2011 +immerwieder +happybirthday +multisync1 +userklon123 +4c656c15c16 +deutzf1514 +marcel1981 +nemesis123 +miniman123 +2512ms2512ms +aerocool123 +jochenlol1 +hanniball1 +55218philip +speedlink1 +dl22082000 +ruhezone.2019 +pandora999 +eishockey1 +his489hpbox# +markus2003 +eduard0979 +monster007 +februar7733 +liana_2901 +nicole1973 +wsdtk0286771 +rammstein! +witerho425 +sophie1999 +leonie2000 +lkjhgfdsa1 +sunshine88 +titanic1912 +fensterscheibe +theresa123 +lischkova1992 +meinemutter +hotline123 +annalena123 +mcchicken7 +adolfhitler123 +wilhelmine +mixery1981 +ams09051995ams@! +nyq28giz1z +lutzifira12 +victoria89 +murmeltier +1maximilian1 +powerofds1 +bluemchen13 +fortuna9554 +bratpfanne +fuss1860ball +hagelsturm1 +k017693267038 +kramerkl300 +leamarie2508 +ho7et1fnldrjevwfkqg0kudje +daniels001 +impreza9628 +jetstream1 +zockimo1998 +julian1997 +daniel334455 +fahrenheit +streem1705 +140659k40228 +passau2509 +celina2008 +thekiller1 +dl1bzb1207 +121086s12735 +hallo123123 +rosumarinu2 +michelle09 +geschichte1 +paintmaker +maulfotze53 +nintendods +snulegab234 +bied44880fre +westerwald +telefunken +adriano281 +notdefined +oliwia2004 +att24.illa +strandkorb +dodgeviper +sahnetorte +passwort19 +muckelmaus +1884klitzo +d16121985g +fleischwurst +hurensohn12 +schwimmen123 +bobmarley1 +lukas240305 +schlagzeug1 +shoppingqueen +ichhassemich +augustinus +i3dyr8olpowgw +baumschule1 +zuckerpuppe +9999999999 +moritz1998 +sommer2015 +feuerzeug04 +dieklasse6a +blutengel1 +101053dani +sparschwein04 +kira2000b2 +schnecke11 +claug3r127 +pascal1999 +jette081004 +duesseldorf +qwertzuiopu +erfurt123@ +softwaere1! +labello123 +t!m!mann06 +dieaerzte1 +marcelinho +catherine1 +lisalina.81 +sternchen6 +schlappi007 +bommel2012 +krankesschwein +annemarie1 +11sternchen22 +montevideo +n7s95kpsgx +matthias80 +housemusik +handball23 +alexander97 +michael1803 +angelina09 +marcel1234 +iloveshopping +montecarlo +malermeister +sebastian1995 +hallo2bayern +chesterfield +falloutboy +jens14081986 +ros1wi2ta3 +thomasbasti +springfield +blutwolf358 +ar30265132 +ageofempires +laura12345 +bumsebiene1 +wittenberg +nadine241212 +obstsalat64 +kerstin1997 +stratocaster +hansdieter +johannes2509 +mississippi +markmayer1 +mayflower1 +feueramt123 +seifenblase +imperium2q123 +tobias1997 +hammerfall1 +fussball50 +christoph! +eminem2008 +cristina123 +magdalena2 +sieglinde1 +leonforte1965 +fcbayern13 +kirsche123 +urbanclassic58 +mercedes12 +blubbblubb +annamaria1 +christoph2 +naruto2009 +apfelsaft123 +playstation4 +fussball98 +bcp201109a +spielaffe123 +melinchen01 +adrian201111..77 +mistwetter1111 +celina1999 +socv3aqp8h +niklas0503 +mitzekatze +inextremo1 +karakartal +sophie2003 +neunzehn19 +uexkbgpp3k +wedding4812 +dsad12w1dsa +mariojosef01 +hopefully123 +intruder86 +ichbinhier +1budweiser +potsdam123 +c.ronaldo7 +bvfntvkwhn +obstkuchen +yamahaxj900 +marianne50 +karl26gustav +excalibur1 +frauenarzt1 +24dioitico +weloveonur1 +sebastian0 +emin3m199061 +rosenkrieg +fladenbrot +katzentier +zaubernuss +oliver2001 +jasmin1998 +ingo310371 +dominik1304 +mummelchen +deputamadre +kiara020106 +avatar1998 +colecto223 +stuttgart2 +8780102932 +tabeatabea2 +panzerknacker69 +wrestlemania +kennwort11 +ichbinderbeste +kakadu2000 +bulletproof +danny-1431 +wintergarten +selina1999 +gerdk02d661 +hardcore1985 +flecki0402 +silenthill2 +bmw123456789 +denise2001 +universum1 +lollipop00 +atzenmusik +hirschmann +amontilado91 +mogwai2005 +felidea2507 +hilberthaus8 +computer67 +neuenkirchen +divemaster11 +eierbecher +interceptor1 +leonie2009 +operation7 +arschloch666 +q123456789 +schockolade +roymakaay1 +blacklight +005863lebreh +porsche356 +nadine1993 +garlipp1995 +mobilat2410 +django1302 +brandenburg1 +burton1234 +spiegel123 +tagebuch01 +worldofwar +schlauchboot +737737jobe +fleischberger1 +hondacivic1 +julian1234 +thisis4you +kleiner-vogel +ich1234567 +fabian1234 +christkind +puppies123 +mk2392012.. +milchnase1994 +werder1988 +passwort16 +vasylets00 +1996a4b26c +oschitz1954 +bim02062000 +pascal1407 +kugelmaus55 +meister103 +prince1fais +haferflocke13 +silroy351993 +sammy23112005 +domenico27198 +circustown.99 +bandito1234 +katarzyna1 +loveforever +pokeking98 +pocketbike +1133gabriele +fussball97 +sabrina1975 +windsurfen1 +rammstein88 +steffi1964 +cassandra2010 +justin2409 +entenhaus1. +jessejames +08g11v50fld +annamarie98 +blumenstrauss7 +fckhockey01 +eschweiler +floandeddie13 +zelinograd +collins12. +3004pierre +23051984al +franziska12 +killer1010 +chemnitzerfc +1990winner +schlingel1 +torsten1965 +speedlink69 +klopper76712 +hannelore2 +max123456789 +123456789r +timrobin99 +stinker123 +brestbrest +coldmirror +darkknight +sonthofen1 +sandmann10 +berlin2015 +meineliebe +sesamstrasse +qwertz1337 +hauskatze1 +marvin1997 +bananenbrot +handball89 +jannik2000 +lana090209 +lisa08032008 +510631philipp +pferdefreunde +wasserfall123 +marcoreus11 +winter2011 +naabecker1 +asdfasdfasdf +38145sting1 +827650cent +myspace666 +vondey1976 +tanzschule +maxwmax123 +alex5691606 +flori1987# +suckmydick1 +abstellraum1 +hengsha_2_23 +boss20081997 +19daniel97 +fucking123 +nopassword150 +robin8862s +mindfreak999 +fingerweg82 +pantera666 +altenpflege +paul1paul2 +harley1958 +asdf123453 +fockengr12 +woodstock1 +spiderman123 +12345schwert +brandenburg +letizia2804 +pentakill1 +wavemaster1 +12345sommer1 +xddeinemudda0 +alexander4 +manfred1962 +pokemon2002 +09102210leo +andermich123 +sabine0509 +opelomega1 +roadrunner1 +wuerstchen +postkarte03 +logitech94 +crossfire1 +7127618abc +deinemudda10 +lilienbluete +1christian +hansa1965! +rode05052005 +sweetangel +piccolino2007 +areyousure1 +bluedragon +ventilator +prinzenrolle +t123456789 +pm49524952 +wetterwachs1 +dursty1198 +andrea1511 +27111994mgfi +karlsgymnasium +knallfotze53 +oliver12345 +asd123asd123 +mev20do@24 +a1349honda +lautern1900 +diezeche42 +schuft03102007 +carabelli1 +fiestaonline +madagascar +motorola1974 +fabian6111996 +mineralwasser1 +!12gonzo34 +erdbeereis +vatoslocos +dunkelheit +15dezember +tigerlilly +geb05121991 +freenet123 +hannes1100 +geraldo153 +michael1981 +lateralus1 +123456789z +akkordeon1 +dorothea12 +bh21051963 +ghhh47hj7649 +mercedes300te@ +1231231231 +pestalozzi +ichliebedich! +0125697482 +celine2006 +hexenkessel +dustinkst1 +kennwort12 +swordfish0 +franziska! +shotonline +feiern29031980 +seestern0802 +blink1821992 +hannes2001 +donnerwetter +kompressor +klavier123 +celine1999 +katzenauge +leonlinda55 +tischkalender01 +knauerhase1917 +labrador04 +mohamed123 +jeremy20082 +hauenstein +unsichtbar +kalendar01 +peacock101 +libanese1996 +anneliese71 +princess123 +frankzappa +hhhhhhhhhh +stella1992 +janina2002 +jasmin1234 +mittelerde1 +fnixfnpxfn1 +verantwortung +hellsangels +bismillah24 +lichterkette +vaffanculo +steffi1984 +fireblade1 +tirositi23 +schloss123 +hackcheck1 +master_pie +141089alois +usuckballz1 +mailcreated5240 +qazwsxedcrfv +fuck_inside +rjirfrgbde +poiuytrewq +translator +fktrcfylhf +deepthroat +multiplelo +sersolution +montgom240 +qwertyqwerty +rjycnfynby +polniypizdec0211 +microphone +gfhjkmgfhjkm +vsjasnel12 +9293709b13 +yjdsqgfhjkm +qazwsxedc123 +fuckinside +good123654 +zxcvbnm123 +mypassword +7777777777 +ghjcnjgfhjkm +anastasiya +zxcasdqwe123 +pufunga7782 +intercourse +12345qwerty +university +absolutely +htubcnhfwbz +masterbate +penetration +0192837465 +nthvbyfnjh +cocksucker +1212121212 +123456789qwe +1qazxsw23edc +vfntvfnbrf +masterbating +1234567890- +qwerasdfzxcv +password99 +ghjcnbnenrf +experienced +cheerleaers +abracadabra +z1x2c3v4b5 +interacial +supersonic +1357908642 +websolutions +rfhfvtkmrf +q1q2q3q4q5 +greedisgood +gangbanged +1z2x3c4v5b +wachtwoord +qweasdzxc123 +chickenwing101 +123456789v +1234567890z +unbelievable +quant4307s +housewifes +flvbybcnhfnjh +8888888888 +123321123321 +ghbdtnrfrltkf +ghbdtndctv +experience +987654321a +1qaz2wsx3edc4rfv +lockerroom +ejaculation +knickerless +contortionist +0987654321q +vflfufcrfh +transexual +pornographic +newproject2004 +masturbation +ilovepussy +comicbookdb +businessbabe +lighthouse +1213141516 +uncencored +shevchenko +masterbaiting +babemagnet +j3qq4h7h2v +0000000000o +watermelon +scandinavian +penetrating +maurolarastefy +fuckmehard +domainlock2005 +cxfcnkbdfz +qqqqqqqqqq +qazwsxedc1 +momsanaladventure +information +dhjnvytyjub +987654321q +17171717aa +yfnfitymrf +techniques +peternorth +insertions +evanescence +8phrowz622 +123hfjdk147 +123456789012 +ssssssssss +doggystyle +5hsu75kpot +3216732167 +0102030405 +qazwsxedcrfvtgb +opensesame +8phrowz624 +postov1000 +lifeisgood +a1a2a3a4a5 +gy3yt2rgls +professional +jediknight +ghjcnjghjcnj +dkflbvbhjdbx +copenhagen +angelofwar +admin18533362 +vfvfvskfhfve +mapet123456 +fkmnthyfnbdf +bondarenko +aleksander +89600506779 +zxcvb12345 +zcxfcnkbdf +qazxswedcvfr +q1234567890 +pussylover +parliament +cyberonline +azsxdcfvgb +23176djivanfros +123456789qwerty +123456789n +zzzzzzzzzz +zaq1xsw2cde3 +smallville +pussylicker +psychnaut1 +prettygirl +oc247ngucz +dkflbvbhjdyf +blingbling +alexandria +1q2q3q4q5q +1111111111zz +whosyourdaddy +salamandra +qwertyasdfgh +moneymoney +girlfriend +duranduran +badnaamhere +89015173454 +4904s677075 +123123123q +1223334444 +t34vfrc1991 +sunderland +parolparol +ilovemyself +connection +astonvilla +541233432442 +3333333333 +1234qwerty +123456789qaz +winchester +qpwoeiruty +pussyeater +moneymaker +ghbdtnghbdtn +fartripper +cnjvfnjkju +12345678900987654321 +123123123123 +1111122222 +0147896325 +wellington +venom121293 +jesuschrist +intelligence +friendship +dashadasha +ctdfcnjgjkm +birthday21 +51051051051 +12345trewq +1234567890s +010203040506 +vanyarespekt +qq123456789 +mickeymouse +fktrcfylhjdbx +fgjrfkbgcbc +6666666666 +12345678901234567890 +102030405060 +sexmachine +rhbcnbyjxrf +iddqdidkfa +fktrcfylhjdyf +devildriver +cxfcnmttcnm +combat123654 +efbcapa201 +89231243658s +12345zxcvb +123456789x +1010810108 +testing123 +romanroman +programmer +passmaster +millennium +matematika +m6cjy69u35 +letmeinnow +helloworld +goodfellas +adrenaline +999111999q +3syqo15hil +123456789qqq +1112131415 +vyjujnjxbt +qweasdzxc1 +neveragain +gthtcnhjqrf +deerhunter +dbrnjhjdbx +ambassador +a987654321 +1pass1page +12345678912 +123123qweqwe +1122112211 +ybrjkftdbx +whitepower +rkfdbfnehf +rfnfcnhjaf +qwertyytrewq +qwerty123456789 +quicksilver +q1w2e3r4t5y6u7 +primetime21 +password1234 +birthday54 +azwebitalia +saun24865709 +989244342a +1357997531 +123qwe456rty +1234567890qw +11111111111 +zx123456789 +zaratustra +zqjphsyf6ctifgu +z123456789 +wenef45313 +verygoodbot +rfnfgekmnf +rfkmrekznjh +pussypussy +pittsburgh +newpassword +luojianhua +lkjhgfdsaz +ledzeppelin +iseedeadpeople +hurricanes +hellothere +everything +computador +bbbbbbbbbb +aaaaaaaaaaaa +supermanboy +80070633pc +4815162342lost +123456789f +123123123a +1010101010 +vfylfhbyrf +transformer +stalingrad +skorpion39 +s456123789 +pornografia +management +justforfun +iaapptfcor +gunslinger +dictionary +deltaforce +dbrnjhjdyf +cyjdsvujljv +birthday299 +apokalipsis +mevefalkcakk +hshfd4n279 +ajcuivd289 +7777755102q +5t4r3e2w1q +31217221027711 +19mtpgam19 +123456789qw +12345678987654321 +unforgiven +tamwsn3sja +superpuper +shadow1212 +puertorico +ptybnxtvgbjy +pppppppppp +password23 +password13 +naughtyboy +lightsaber +lhbjkjubz2957704 +kbnthfnehf +hedimaptfcor +ghhh47hj764 +fucktheworld +firestarter +elizabeth1 +cosmopolitan +clubpenguin +chessmaster +cegthgfhjkm +byajhvfnbrf +berezuckiy +applesauce +alternative +4815162342a +4444444444 +123456789zxc +1234567890w +1234567890qwe +111222333a +yfcnzyfcnz +yanshi1982 +sevastopol +rrrrrrrrrr +q1w2e3r4t5y6u7i8 +password00 +oooooooooo +olegnaruto +necromancer +mechanical +mastercard +loveislife +lost4815162342 +ladyffesta +juggernaut +grandmaster +ghjcnjrdfibyj +ghbdtngjrf +fkg7h4f3v6 +cheburashka +bangladesh +andrewjackie +olcrackmaster +installdevic +digitalprodu +89172735872 +5544332211 +1qazxsw23edcvfr4 +159753456852 +12qw34er56ty +1234qwerasdf +12345678qwe +123456789qq +123456782000 +000777fffa +winnipeg261 +whatthehell +streetball +rjhjkmbien +revelation +peanutbutter +oldsmobile +motherfuck +mangust6403 +lsia9dnb9y +lovelovelove +kingfisher +jetbalance +ingodwetrust +incredible +ghjrehfnehf +fcbarcelona +challenger +birmingham +auckland2010 +hypnodanny +89211375759 +88002000600 +1236547890 +123456789101 +1234567890zzz +11111111111111111111 +w2dlww3v5p +ujkjdjkjvrf +tttttttttt +tigerwoods +suckmycock +qwertyuiop1 +quiksilver +qawsedrftgyh +powerpower +ontherocks +omgkremidia +millionaire +markhegarty +klapaucius +jigei743ks +ilovegirls +grasshopper +ghjcnjqgfhjkm +football12 +ekaterina20 +caterpillar +beautiful1 +an83546921an13 +alphaomega +froinlaven +boy4u2ownnyc +742617000027 +42qwerty42 +41d8cd98f00b +32615948worms +1958proman +123321qweewq +1029384756q +01telemike01 +0147258369 +wolverines +virtuagirl +ticketmaster +thebeatles +technology +studioworks +scoubidou2 +sasha12345 +sacramento +pornography +performance +perfection +peppermint +nhfdvfnjkju123 +nemvxyheqdd5oqxyxyzi +mmmmmmmmmm +millertime +luckycharm +lololyo123 +kristopher +justinbiebe +jesusislord +greengreen +gordolee85 +gjytltkmybr +gjikbdctyf +ghjcnjrdfif +ghjcnbvtyz +gggggggggg +gblfhfcbyf +flintstone +domination +cyecvevhbr +coorslight +cegthgegth +brucewayne +blackhawks +ajnjuhfabz +a3eilm2s2y +lp2568cskt +987654321z +5432112345 +26429vadim +19952009sa +1986irachka +12345asdfg +123456qwert +123456789zx +123456789. +1133557799 +0000000000d +vfhufhbnrf +ufhhbgjnnth +timberwolf +timberlake +telecaster +tadmichaels +sweetpussy +svetasveta +stomatolog +spam967888 +shamanking +richardson +rfhfrfnbwf +rfgecnfcerf +reanimator +qwerty2010 +punksnotdead +production +premiumcash +password21 +password10 +nccpl25282 +multiplelog +motorcycle +minhasenha +mandragora +mandarinka +lumberjack +liverpoolfc +kaliningrad +iloveboobies +grandorgue +gfhfcjkmrf +fuckyouguys +fuckyoubitch +fighting54 +eybdthcbntn +dkflbdjcnjr +disneyland +deeppurple +dddddddddd +dctvghbdtn +cristopher +cheerleader +calculator +bluedevils +birthday10 +p030710p$e4o +jimandanne +installsqlst +80637852730 +7elephants +7418529630 +4294967296 +3f3fpht7op +38gjgeuftd +1qwertyuiop +1a2s3d4f5g6h +123456qwer +123456789i +123456789c +123456789as +1234554321q +123123123123123 +10987654321 +0cdh0v99ue +0147852369 +01234567890 +vfvfgfgf123 +vfrcbvvfrcbv +vfhvtkflrf +timberland +sundaypunch +stalker123 +snusmumrik +simpleplan +serendipity +selfok2013 +scoubidou6 +riverplate +redemption +qwertgfdsa +pussybitch +prosperity +portsmouth +philadelphia +p0o9i8u7y6 +nightcrawler +neworleans +nastya1995 +montgomery +mathematics +matematica +magical123 +llllllllll +littlegirl +littlebitch +lfplhfgthvf +kozanostra +kobebryant +kd189nlcih +juancarlos +jlbyjxtcndj +ilovemylife +iampurehaha2 +hammertime +gxlmxbewym +gthtrhtcnjr +greenlantern +ghjcnjnfr1 +ghjcnjgbpltw +georgetown +gatekeeper +galatasara +fktrcfylhjd +cytuehjxrf +constantine +computadora +cjdthitycndj +cincinnati +chrisbrown +chicken123 +cfkfvfylhf +birthday100 +asdfghjkl; +angelochek +anastasija +alinaalina +installutil +description +87e5nclizry +80988218126 +7jokx7b9du +413276191q +1597532486 +1472583690 +123456789aaa +11111aaaaa +0o9i8u7y6t +zxcvbn3215 +zjses9evpa +zaqwsxcderfv +yfcnhjtybt +writerspace +walkman555 +vladislava +vinogradov +tupacshakur +tdfyutkbjy +table54781 +sweetdream +sexpistols +service321 +schoolgirlie +sashasasha +saqartvelo +reviewpass +respublika +qwertyuiop10 +qazxswedc123 +qazwsxqazwsx +qazwsx1234 +playground +philosophy +passwordstandard +nhfycajhvths +nhecsyfujkjdt +nuaddn9561 +montenegro +masturbate +massimiliano +mashamasha +mapleleafs +lytghjgtnhjdcr +lotrfotr34 +livestrong +littlebear +kwiatuszek +kanmax1994 +jxfhjdfirf +jjjjjjjjjj +intermilan +intelinside +headhunter +hammerhead +godisgreat +gjhjlfcjqrb +ghjcnjabkz +gfhfvgfvgfv +firstone123 +dfktynbyrf +department +cockgobbler +cgtwbfkbcn +celtic1888 +c43dae874d +butterbean +bullwinkle +bpgjldsgjldthnf +blahblahblah +baseball12 +awdrgyjilp +astonmartin +anderlecht +am4h39d8nh +alabama123 +accounting +accountbloc +paraklast1974 +hardwareid +9z5ve9rrcz +9638527410 +9085603566 +89876065093rax +2468013579 +1qa2ws3ed4rf5tg +1928374655 +141312190296q +132forever +1313131313 +1234qwerasdfzxcv +1234567qwertyu +123456789y +123412341234 +111222333000 +zcxfcnkbdfz +xxxp455w0rd5 +xiaoyua123 +weihnachte +websolutionssu +vfhbyfvfhbyf +v123456789 +uuuuuuuuuu +throatfuck +subscriber +studmuffin +stinkyfinger +rjycnbnewbz +rhfvfnjhcr +qwertyu123 +qwerttrewq +queenas8151 +protection +prisonbreak +presidente +playstatio +philippines +passwordpassword +nondriversig +nokiadermo +nikita2000 +nikita1998 +musiclover +morganstanley +minnesota_hp +millerlite +marinochka +luansantana +lovezp1314 +loploprock +littlewhore +littleslut +littlefuck +kravchenko +jokerjoker +jesuschris +jedimaster +itsasecret +iloveyou143 +ilikepussy +hondas2000 +helicopter +guitarhero +greenhouse +goodmorning +gjkysqgbpltw +ghjuhfvvbcn +ghjrjgtyrj +freyfvfnfnf +fiorentina +felicidade +fatima753357 +engineering +drumandbass +donkeykong +dogphil3650 +difference +demolition +cuntfinger +construction +confidence +comicbooks +collection +cnfnbcnbrf +christiaan +charlie111 +cccccccccc +candyeater +bmvm3e46gtr +bltynbabrfwbz +blackwhite +blackhorse +blackadder +birthday133 +badiman28200 +asasasasas +antananarivu +animal2000 +activation +abcdefghijk +rfnthbyf1988 +polniypizdec110211 +phezc419hv +lzhan16889 +formatters +9988776655 +9085084232 +8096468644q +789456123a +59382113kevinp +2dumb2live +192837465q +147258369a +1346798520 +123qweasdzx +1234qw1234qw +1234567890qwerty +1234567890qaz +1234567890p +1234567890o +1234567890987654321 +123321456654 +122333444455555 +111222333444555 +0987654321a +0123654789 +zxcvbnmzxcvbnm +zcfvfzkexifz +zaq12wsxcde3 +wallstreet +vladivostok +ubvyfcnbrf +sunshine69 +stickdaddy77 +steamforums +spartak1922 +something1 +sleepyhollow +skylinegtr +seemnemaailm +satisfaction +santaclaus +rockhopper +rjkjrjkmxbr +rashley198 +rainforest +qwertasdfgzxcvb +qwaszxerdfcv +qazwsx12345 +q1w2e3r4t5y6u7i8o9p0 +polina2009 +pointblank +plextsofttm +percussion +password22 +ontheoutside +ntktdbpjh1994 +nthvbyfnjh2 +nottingham +nikolaevna +neverwinter +ne_e_pod_chehyl +nastyanastya +myjdxtcxks +mortalkombat +meditation +mattylad10 +matchbox20 +maintenance +ma123123123 +lkjhgfdsazx +littleminge +lissalissa +kryptonite +kolesnikov +kensington +katyakatya +kamelia2011 +jamesjames +imagination +iloveyou11 +ilovemyfamily +ibpjahtybz +hollister1 +hijodeputa +heavymetal +happyhappy +hallelujah +gundamwing +gtnhjpfdjlcr +ghjuhfvbcn +ghjatccbjyfk +gfhjkm1234 +gerasimova +fynfyfyfhbde +fyfnjkmtdyf +fufnfrhbcnb +foundation +fitzgerald +fishfinger +excellence +devastator +controller +contrasena +candyfinger +c7e4f8ezqh +bujhmbujhm +boredboi4u +birthday28 +birthday27 +birthday26 +baseball11 +awo8rx3wa8t +asshole123 +anytimetoday +antichrist +angelangel +amadeusptfcor +afrika2002 +webuivalidat +specialinsta +sataniv1993 +r3vi3wpass +lockingservi +kudos4ever +999999999999 +987654321g +89032073168 +671fsa75yt +6215mila6215 +4815162342lf +2606642yra +23wkoa0fp78dk +1q3e5t7u9o +123ewqasdcxz +123456789qwer +123456789j +123456789asd +1234567892000 +1234567890m +123456789000 +1234567887654321 +1234562000 +1111111111a +111111111111111 +10293847qp +014702580369 +zxcvbnm123456789 +zxcvbn123456 +zxcvbasdfg +zxcvb09876 +z1x2c3v4b5n6m7 +z1x2c3v4b5n6 +ytpfdbcbvjcnm +yoshimitsu +ybrjkftdyf +wwwooo1234 +winstonone +wethepeople +wazzkaprivet +vitalik123 +vfuybnjajy +vbhjndjhtw +universidad +ubnkthrfgen +tylerca310 +thundercat +thugstools +thesimpsons +thecakeisalie +sutvsc5ysaa +superpower +stevesmojo +specialized +specialist +sergeevich +seniseviyor +sdh686drth +sanfrancisco +sagittarius +rockbottom +qwertyuiop12345 +qwertyuiop12 +qwerty1992 +qaz26101778 +progressive +portishead +pineapple1 +pfqwtd27121988 +pfhfnecnhf +peugeot406 +password88 +passtrader +pandemonium +onetwothree +nightshade +nhfycajhvth +nhfrnjhbcn +natasha123 +nastya2010 +nastya1997 +msoracle32re +monkeybutt +milfhunter +maxmotives +marmeladka +margherita +manchester1 +maisuradze +love777321777 +lk9slwgh3x +ktjynsq40147 +krasavchik +konovalova +kazakhstan +kalifornia +jesussaves +jasmine123 +imjakie123 +iddqdiddqd +iamawesome +hjpjxrf23062007 +harrypotte +gunsnroses +gthtrfnbgjkt +gjdtkbntkm +ghtpthdfnbd +ghjnbdjcnjzybt +ghjgfufylf +gearsofwar +fuckthroat +fuckthemall +fuckoff666 +fuckmylife +fuckintits +frozenfish +freeaccess +forzainter +fktrcfylth +fgtkmcbyrf +fgjkbyfhbz +electronics +e6pz84qfcj +dragonforce +dirtywhore +dantheman123 +daddysgirl +compatible +commercial +cnfc35762209 +cheeseburger +cfvfzrhfcbdfz +byyjrtynbq +bvncnbnvvbn +bushmaster +boy1cool23 +blackheart +birthday36 +billionaire +azerbaijan +awesome123 +astra334566 +asdfgh123456 +artemartem +alkanaft123 +aksjdlasdakj89879 +a789456123 +performing +networkingpe +nick1234-rem936 +jwhw6n1742 +infalicall +corperfmonsy +99strenght +89semtsriuty +89614774181 +89132664230 +89063032220m +87654321vv +4815162342q +420842084208555 +2347172123 +2143658709 +1rus27540102 +193570356033 +123qwerty123 +123654789a +12345qwert7 +123456789zz +1234567890v +1234567890d +12345672000 +123451234512345 +1231231234 +0123698745 +000000000000 +zxcvbzxcvb +zxcvbnmmnbvcxz +zexts364325 +zcfvfzrhfcbdfz +ytpyfrjvrf +xxpa33bq.adna +xtkjdtrgfer +wsx22wsx22 +widespread +widescreen +werty12345 +w1w2w3w4w5 +voxstrange +volkswagon +vladimirovna +vbybcnthcndj +vef6g55frz +utyyflmtdyf +userexecute +uniqueness +understand +tigertiger +technician +taylormade +sylviahans +sutherland +supersuper +sunglasses +submission +stefanescu +statistika +slushslush +simferopol +sdicmt7seytn +santamaria +sagitarius +ss6z2sw6lu +rockandroll +rhfcyjzhcr +rfnthbyjxrf +rfkbybyuhfl +rfkbajhybz +rfcgthcrbq +rerhsybrcs +razorblade +rangerover +qweasdzxc12 +qazwsxedcrf +qazwsx123456 +qaz123wsx456 +q80661658441 +q2w3e4r5t6 +pyfrjvcndj +ponomarenko +pointbreak +pjsheridan +pflhjncndj +password33 +orochimaru +operations +nostradamus +noonehackme +nikita2010 +nikita1996 +nikiforova +nhbybnhjnjkejk +nhbujyjvtnhbz +nevergiveup +nesterenko +necronomicon +navigation +nastya1996 +naruto2010 +mousemouse +mountaindew +mogwai1976 +mishaoooyeah +minniemouse +michelangelo +mariamaria +marcuseckos +lytdybrbdfvgbhf +ljcnjtdcrbq +littlehole +kolesnikova +knucklehead +kittykitty +kirill2010 +killkillkill +keraskeras +katastrofa +kalashnikov +josephphone7 +jjjjjjjjjjjj +jesuslovesme +jesusfreak +jackhammer +ivanivanov +ipo54tj45uy856 +investment +internacional +iloveyoubaby +iloveyou22 +iiiiiiiiii +ibragimova +housemusic +horsepower +handkerchief +h72sfibbnl +gtasanandreas +grimreaper +greatwhite +godzils4s7 +gjyjvfhtdf +gilbert2707 +ghostrecon +ghjnbdjufp +ghbdtn12345 +gfhfktkjuhfv +g00dpa$$w0rd +fyfnjkmtdbx +foreveryoung +flyfishing +flashpoint +flashlight +fktrcfylhjdf +fggjkbyfhbz +ffffffffff +fdnjhbpfwbz +fcnfkfdbcnf +entertainment +dunnowho89 +dreamonline +dontforget +domodedovo +dmiller12as +djljghjdjl +dfcbkmtdyf +dancedance +cthnbabrfn +crepusculo +crazyhorse +cosanostra +consulting +cghfdjxybr +c123456789 +bullnuts2003 +bot_schokk +blacksonblon +blacksheep +blacklabel +birthday52 +biblioteka +battlestar +baseball21 +baseball17 +apocalipsis +antikiller +alphabravo +alpha135792468 +alisaalisa +aleksey1986 +afireinside +afghanistan +adoxreadme +abercrombie +abcd123456 +qwe1234567 +larterlarter +itachi1995 +hd764nw5d7e1vbv +h1y4dua229 +fromvermine +findaupair007 +bhrh0h2oof6xbqjeh +ad12345678 +accessibilit +a514527514 +9874563210 +9731553197 +9105425888 +9021090210 +89181502334 +89057003343 +80972694711 +80361665abc +789456123q +787898mich +7410258963 +70780070780 +5858855abc +5152535455 +4rdf_king7 +4815162342z +20162016up +1qaz1qaz1qaz +1a2a3a4a5a6a +19960610ilja +1994200414 +1324354657 +12andriy14 +12345qazwsx +12345q12345 +123456zxcvbn +1234567891011 +1234567890qwertyuiop +123456789* +12345677654321 +1234554321a +1223505sayana +111222333444 +1111111111q +070793monolit +050605rostik +zxcvasdfqwer +zsxmr7sztmr +zobrdjlrb1 +zaqxswcde123 +z1234567890 +yurkamaliy +ytrhjvfycth +ytdpkjvfti +yfnfif2010 +yk66o2kzpz +wonderwoman +witchblade +westminster +weaknesspays +wcrfxtvgbjy +vyjujltytu +vvvvvvvvvv +vtnhjgjkbnty +vp3whbjvp8 +villeneuve +viktorovich +vfrcbvtyrj +vesy7csae64 +velocidade +vbyfcnbhbn +valentinka +underwater +undergroun +uehby92pac +ublhjgjybrf +travelmate +trafficracer +tottenham1 +thunder123 +thirteen13 +tfjunwptzsjp +taser334455 +syncmaster740n +sweetiepie +sweepstakes +svensps820 +sunflower1 +subhanallah +starscream +stalker777 +stabilmente +splurgeola +southampton +smeshariki +silverstone +silvermoon +silmarillion +sasasasasa +salocaluimsg +runescape123 +ruffryders +rjytwcdtnf +rjyjdfkjdf +rjcvtnbxrf +rikitikitavi +rfkfiybrjd +resistance +relentless +registration +rattlesnake +qwqwqwqwqw +qwertyuiopasdfg +qwertyuiop123456789 +qwertyasdfg +qwerty54321 +qwertqwert +qwaszxqwaszx +qazwsxedc12 +q1q2q3q4q5q6 +prokopenko +pratap1245 +powerrangers +powerlifting +pornoporno +plhfdcndeq +perasperaadastra +passionate +parabellum +p9ujkuf36d +nnnnnnnnnn +nikita2002 +nikita1997 +nightrider +need4speed +nastya1999 +nakedteens +n123456789 +monsterkill +misiaczek1 +maximiliano +matheus123 +masseffect +marinamarina +made40media +ludicgirls +luckycharm3 +lonestar44 +littlecunt +limon32988 +lalalalala +l.qvjdjxrf +ktutjyth333 +kriginegor +komltptfcor +klubnichka +kirill2002 +kirill1999 +kilimanjaro +keysersoze +karapetyan +jumpmaster +juicyfruit +jkh4545jhk +jesuscristo +jamiroquai +jameslewis +jacksparrow +jne990pq23 +isvipebaby +invincible +interfaces +informatic +idspispopd +idontknow1 +huangjin1987 +hondastars +holyspirit +hocuspocus +highschool +heartbreaker +harekrishna +gunayka1995 +guadalajara +greenapple +gladiator5 +gjyjvfhtyrj +gjkbnjkjubz +gillingham +ghtdtlvtldtl +ghjcnhfycndj +ghjbpdjlcndj +funnybunny +fuckinglove +fuckfuckfuck +freetraffic +freelander +freedom123 +frederiksberg +forzamilan +forgetmenot +foreverlove +football11 +fktrcfylh1 +ereyes4269 +elsalvador +earthquake +e1l2e3n4a5 +dvtcntyfdctulf +dtynbkznjh +dragonslayer +december12 +dctvcjcfnm +danildanil +dance4life +cuntlicker +cranberries +corinthians +coolbugi2000 +compliance +cnhjbntkmcndj +cnfhjghfvty +cnfdhjgjkm +clementine +civilization +cippalippa +chupakabra +chucknorris +checkitout +cheaphornybastard +charmander +charliedog +cgtkcbyuth +cgfhnfrxtvgbjy +cagliostro +buccaneers +bonneville +blackstone +blackmagic +baseball10 +azerbaycan +avadakedavra +antoshenechka +antihero77 +andrey1992 +alternativa +alternativ +alteclansing +allahuakbar +alejandro1 +aezakmi123 +administrato +abracadabr +ababagalamaga +aaaaaaaaaaa +a123456789a +uninstallsql +temppassword +soso123bbb +productid20f +polniypizdec1102 +jhon@ta2011 +idedevicep0t +genuineintel +gankutsuou1989 +f64579820f +eulacomplete +efwe5tgwa5twhgd +czps5nyndwcksc +changelangms +99999999999 +951753852456 +92k2cizcdp +89023346574 +777777777777 +741852963q +7410852963 +675675675a +5432167890 +456456456q +4465134444 +3478526129 +3322607093 +3240500777 +31321dj51982 +30secondstomars +3012292113 +2267137151 +2222333344445555 +1qazwsxedc +1928374650 +1324354657687980 +123sas4758 +123qwertyuiop +123698745a +12369874123 +1236987005 +1234567qwerty +12345678qwertyu +123456789azat +12345678999 +123456789987 +123123123z +1213141516171819 +112358132134 +111222333q +100894olol +0987612345 +0528325452mr +02987654321 +zn87x54mxma +zghjcnjcegth +zelenograd +z1z2z3z4z5 +ytrhjvfyn10 +yourmother +youngmoney +yjdjvjcrjdcr +yjdjrepytwr +yflz13041976 +xpressmusic +wolfenstein +wildflower +whiterabbit +whitehouse +weihnachtsbau +waterfalls +w123456789w +vtkmybrjdf +vinogradova +vfylhfujhf +vanderbilt +ufdyfrecjr +tumbleweed +transistor +traktorist +tihomirova +thunderbolt +thiaguinho +thegreatone +sweetsweet +sweetdreams +supersport +supermodel +superman69 +summer2010 +suckmyballs +successful +stonehenge +stensten12 +stalker2010 +sm4llville +slonopotam +skateboarding +showmethemoney +sfhj5484fgh +serggalant +sergey2010 +serg123111 +seo21saafd23 +sektorgaza +scubadiver +scratchman +sasa123321 +santaklaus +samsungs5230 +salmankhan +rockohamster +rhbcnbyf123 +rfrfirf123 +reymisterio +retribution +rendezvous +rainbowsix +r3r3vi3wacc3ss +qwertyuiopasdfghjkl +qwertyuiopasdfgh +qwertyasdf +qwerty2000 +qwerty1993 +qwerty123321 +qw12er34ty56 +quarantine +qrg7t8rhqy +qaz1wsx2edc3 +q2w3e4r5t6y7 +q1w2e3r4t5y +q123123123 +q1205199333 +pyfrjvcndf +purplehaze +pulpfiction +providence +properties +princess12 +praetorian +porovoz123 +polina2008 +polina2005 +pocahontas +piggy15708 +photography +perfectexploiter +password101 +pashademon +p51mustang +ordinateur +oduvanchik +nwctrinity +nomeacuerdo +nochnik104 +ninjamonkey +nikita2011 +nikita1994 +neznakomka +newt7899onrs +neversmile +neversaynever +neversaymypassword +narutouzumaki +monkeynuts +mississipp +milesdavis +miguelange +manchesterunited +loveplanet +lovehurts1 +louisvuitton +lothlorien +loneranger +lizardsquad +littlefucker +lineage123 +leprechaun +leedsunited +leadership +lastchance +l0sk2e8s7a +kristina123 +kournikova +kmh12025476 +kirill1996 +kirill1995 +kickboxing +kbytqrfpkj +kbndbytyrj +kasablanka +jobshop200 +jmhj5464dcx +jesuslives +jeffgordon +j123456789 +intervention +includecatal +iloveindia +ilikecheese +ihateniggers +igeldcheat +hunnybunny +hubbahubba +hilaryduff +hhhhhhhhhhh +heisenberg +heartagram +h_froeschl7 +gtnhj328903 +gthgtylbrekzh +gthcgtrnbdf +greengrass +greenfield +gossipgirl +googlecheckou +goldhill25 +gjkyjkeybt +gianfranco +ghjcnjdkfl +ghjcnbnewbz +ghbdtncerf +gfhjkm2011 +gfhjkm123456 +geniusgenius +general007 +fuckingshit +football24 +football22 +football10 +flvbybcnhfwbz +fgtkmcbyxbr +ferrari360 +expedition +evangeline +eqes606898 +electrical +ekx1x3k9bs +eghfdktybt +dreamworks +dollarbill +diunilaobu8* +dirtysouth +dfgdrb5se4 +destination +denisdenis +denis12345 +den1020834880 +degenerationx +dazdraperma +daugavpils1 +darjeeling +cxfcnkbdxbr +cumberland +ctrnjhufpf +consultant +constitution +comedyclub +colchester +coccinella +ckfltymrfz +cjybthbrcjy +cjcfnmdctv +chocolate2 +charleston +characters +cgfhnfrvjcrdf +cfvfzkexifz +cfvfzcxfcnkbdfz +cfifvfif211 +cbvtycbyjrbz +bruteforce +brotherhood +bossyak123 +bluemonkey +blueberry1 +blackshadow +bezparolya +bestpker09 +bbbbbbbbbbbb +bassmaster +baseball14 +baseball123 +barbariska +barabashka +badreligion +azsxdcfvgbhn +awdqseawdssa +automobile +ashleigh69 +asdfdsasdf +asdasdasdasd +anniversary +andrey2010 +andrey1234 +alisokskok +alexsander +alertpaydoubl +alertemailms +aleksandrova +aleksandrov +aionrusian +agnieszka1 +accountant +abdullayev +abcdefghijkl +a58wtjuz4u +a550777954 +vsavb7rtui +tojiik85521133 +smokie1994 +passwordassword +oap9uto293 +nloq_010101 +nicrasow212 +krzysiek12 +kl?benhavn +h2tmc4g358 +deviceclass +callscesetup +987654321w +987654321d +89128830153 +89055521933 +80990606390 +80969260620 +80966095182z +80679047880 +80672091913 +80663635606 +80633459472qw +6969696969 +68iypneg6u +5647382910 +5566778899 +54322q22345 +4815162342s +4648246482 +3454051maksim +277rte87hryloitru +2507905048 +2244668800 +20102010ss +1qaz2wsx3ed +198919891989 +198719871987 +193711101994a +159753258456 +159357159357 +14bestlist +1488ss1488 +1478963215 +147258369q +131313131313 +1302alex1994 +125712571257d +123qwerty456 +123qazwsxedc +1234567899876543 +123456789123456 +123456789101112 +1234567890l +1234567890g +1234567812345678 +123456654321a +1111111111111 +0995359291 +010203040506070809 +..qlvvcvdeero +zzzzz11111 +zxcvbnm1234 +zxcvbbvcxz +zvezdochka +zaxscdvfbg +zaqxswcdevfr +z19375481z +youngblood +xxxxxxxxxxxx +x72jhhu3za +x26j12m88y +wonderful1 +witchdeath +wintermute +willywonka +widowmaker +welcome2suzan +w27sv7tupf +videogames +vfvfyntyjr +vfubcnhfkm +vbhnhelvfq +vbhjplfntkm +vascodagama +uzbekistan +uthfcbvtyrj +unitedstates +turismo879 +trustno1k0 +truckdriver +tournament +theoldlady +thatsright +temptation +tdutymtdbx +tarantino123 +sysinfomsg +superstar132 +sudakov1987 +submissive +studioware +stonecold1 +stefyebuio +springsteen +springbreak +splintercell +sonyericson +soledad321 +snvd9d8q4r +snowboarding +snickerbar +slickwilly +shovelhead +shokoladka +shmoogle13 +shelbygt500 +sheetmetal +shapovalov +shakespear +sexisgreat +sebastiaan +scooter123 +sasha050386 +sanantonio +samimaxi1212 +salamsalam +saintseiya +royalflush +roundhouse +ronorberg88 +rollerblade +rjycnhernjh +rjycnfywbz +rjpfyjcnhf +rjktcybrjdf +rhtvfnjhbq +rhjrjlbkutyf +revolutiontt +revoluinfo123 +resurrection +republican +repmrf1992 +relhzdwtdf +registered +reflection +redsox2004 +redoctober +rw6sw94284 +qwertyuiopasdf +qwerty654321 +qwerty2011 +qwerty1995 +qwerty1991 +qwerty1987 +qwerty1234567 +qwe123rty456 +qqqqq55555 +q2w3e4r5t6y +punishment +productions +princess11 +prevedmedved +powerstroke +ponomareva +pmdmscts2000 +pipikpipik +pipefitter +photograph +peterparker +permission +periwinkle +perfectworld +pennington +password66 +password321 +password2011 +password12345 +parliament12345 +p8sk7qrg7z +opportunity +olga221189 +oksana1975 +okcomputer +nothing123 +norkapril4 +nikita2001 +nikita1999 +nikita1995 +ngik2ggxpg +negjqgfhjkm +ndjhxtcndj +navigating +naughtyamerica +nastya2011 +nastya1998 +mycomputer +muthafucka +motherwell +motherboard +mornington +mobilephone +mno571krq9 +mitrofanov +mishamisha +miroslava1 +millonario +mickeymous +medvejonok +mediterraneo +meathead99 +masquerade +marten7171 +marsel1986 +marina2010 +marina1994 +marina1983 +mariahcarey +marguerite +marcoantoni +maprchem56458 +manunited1 +maninblack +luba100295 +lovestinks +louisville +longisland +longhorns1 +livingston +liverpool123 +litvinenko +littledick +literature +lekkerding +lebronjames +leavemealon +leatherneck +leatherman +kolobok123 +keepitreal +karina2006 +karambula82 +jxsr000426 +juventus10 +jollyroger +jogabonito +jeanpierre +jawbreaker +jaishriram +jackrabbit +itsallgood +island18pass +inspiration +inquisitor +informatio +industrial +independence +ilovlay162 +ilovemymom +identities +i3zc3nee8o +hydroptfcor +hummingbird +hitsbooster +herooflife +hellsbells +hellodolly +gunnerhead +greatlivaja +graafschap +goodwrench +gladiator3 +gladiator2 +gjkmpjdfntkm +gjkbyf5743 +gide11ok9b +ghtrhfcyfz +ghjdjrfwbz +ghjcnjvfhbz +ghjcnjghbrjk +ghbdtnvtldtl +ghbdtnfylhtq +gfgjhjnybr +generallee +gamemaster12 +fyukbqcrbq +fypjhbr007dfcz007rfr +fva27091994 +fuxedboost +fuckedupshit +frenchkiss +formulaone +football21 +football15 +fooma091194fds +fktrcttdyf +fktrcfylhbz +fishyfinger +firefighte +fire231989 +fgghjrcbvfwbz +feyenoord1 +fatboyslim +fantastic4 +eyeshield2 +euamominhamae +equilibrium +elitedollars +electronica +eikocarol13 +dzxtckfdjdyf +dyenhtdtyyj +dsdctujdyj +dsdctufdyj +drummerboy +dragonlance +dragonfly1 +doubleplay +domainlock2004 +dkflbckfdf +divemaster +diosesamor +diamond123 +dfkthmtdyf +dfczgegrby +deathmetal +dctgblfhfcs +dagestan05 +d987654321 +cradleoffilth +costruzioni +copacabana +control123 +configured +conference +concepcion +comicbookdb1 +cnhfnjcathf +clitlicker +clean874524 +chumley4870 +chickenhawk +chicagobulls +chevytruck +cghfdtlkbdjcnm +cdtnfcdtnf +carmichael +capricorni +cannabis90 +c0m1cb00kdb +byrdbpbnjh +byascyther +bwcc2009new +bvgthfnhbwf +butterflies +britney916 +brickhouse +brainstorm +bobthebuilder +blackpussy +blackknight +blackblack +birthday55 +beverlyhills +berlin1945 +benchpress +baseball33 +baseball24 +baseball13 +bartholomew +baloncesto +backstreet +babycakes1 +athens2004 +asdfgzxcvb +asdfggfdsa +asdfgasdfg +architecture +antipidrid +anhyeuem123 +andrey1994 +america123 +altagracia +allthetime +alexandrovna +alex123456789 +ajnjfggfhfn +afynfcnbrf +abc12345678 +wu994216433 +terminating +soso123456 +serializatio +s968831778s +providername +processors +processing +nodataincolumn +maksimka2134 +msorcloledbr +leon9097579 +l8cjy83u3b +kornography +isymwrapper +hornet1983 +gimopoy123 +fixedbutton +f19911991f +enumerator +darkjavell9 +coinstallers +barbarian13 +acckeyboard +a9n87u2jved9y +9n1vac6wgw +987654321m +9876543211 +9876543210q +923650asdfghjkl +8mlzimzslmjf +8hgy16vqvq +89183891205 +89105249698 +852456852456 +80503722372 +80502542102 +791017941s +7896541230 +7896321456 +7539514862 +589632147a +55555aaaaa +555555555555555 +5532361cnjqrf +5236987410 +4815162342x +4062056totoot +333333333333 +32158900666 +300cgfhnfywtd +2947819540 +28401losali +27051993my +2419411945 +234589frendz +228gfgbhjcbv +22222222222 +1qazxcvbnm +1q2q3q4q5q6q +1password1 +199419941994 +1717171717 +1702alex1991 +1597534682 +147852gogozen +147852369z +147258369z +1471214712qwe +142536475869 +1357902468 +12369874qq +123456vova +123456asdfgh +123456abcd +1234567qwe +123456789ss +1234567898 +1234567895 +1234567890k +1234567890b +1234567890as +12345665432 +1234560987 +123321qwerty +1223344556 +11qq22ww33ee +1122334455a +11223344556677 +11111qqqqq +1111111111m +1029384756a +0xfhs03269 +099453784sem +0989161740 +09876543211234567890 +05021991inna +025802588520 +015536822v +0123456789a +010823dfcz +0099887766 +..xrlqiyeopco +zxcasdqwe1 +zxcasd1234 +zolushka66 +zktcyn74ir +zjlyfnfrfz +zhao9622109 +zcegthfynjif +zaqzaqzaqz +yyyyyyyyyy +ytpyfqrf123 +ytnegfhjkz +yteuflftim +ytcnthtyrj +yjdsqgfhjkm1 +yjcnfkmubz +yfcheyfirjke +ybrnjrhjvtyfc +ybrjkftyrj +yawdjof117 +xxxxxxxxxxx +xxx7847360 +xthyfzrjirf +xjovwwhkgy +xcabczxabcz +x5etmuwe90 +x3luym2mmj +worldofwarcraft +woodpecker +wolverine1 +witchcraft +winfieldsc +whitetrash +whitetiger +wheelchair +weihnachtsbaum +wegangster4lifebitch +websolution +vova123456 +vladik2010 +vjzghtktcnm +villanueva +viktorovna +vfvjxrfvjz +vfvfljhjufz +vfrcbveirf +vfrcbvbkbfy +vfhbjytnrf +vbxtmuvofh +vbkbwbjyth +v80953054464 +uv1n5tz869 +usersgroups +unimportant +unicorn123 +tylerdurden +twentyfour +ttttttt900 +tralala101 +toyotarav4 +toothpaste +tomkaulitz +thunderball +thomas2010 +tetriandoh +tequieromuch +temik201600 +telefonica +tdljrbvjdf +taxidriver +tawny21bii +tapac971111 +tanya12345 +taisabloom +superman23 +superfreak +streetking +stratovarius +stas123456789 +starwars77 +starkiller +stairwaytoheaven +sportsport +spongbob123 +spiridonov +spellbound +speedracer +speeddemon +soundgarden +sonypictures +solomatina +softball21 +soccerball +snowpatrol +sliveddevils +ske9k9tch3r5w9s +sjxrnc7e6nt +silvertree976 +silverbullet +sidvicious +serhthsjsth +sergei2010 +serega1994 +serega1992 +september19 +senisevirem +sealink123 +seaisle1970 +scorpionking +saneksanek +samsung2010 +samirsamir +samantha12 +sakartvelo +s2fdsp8g6i +s1s2s3s4s5 +rutherford +ruslan1996 +ruslan1994 +runningman +roga4evi411 +rockisdead +rjycekmnfyn +rjgbkrf666 +rjcnzrjcnz +riodejaneiro +ridiculous +rhjcfaaxtu +rhfcjnekmrf +rfvbrflpt2011 +rfhm2tpx47 +rfgbnfyrbn +radiance29 +qzwxecrvtb +qwertyuiop0 +qwerty2009 +qwerty1989 +qwerqwerqwer +qwer123456 +qazxswedcvfrtgb +qazwsxedcrfvtgbyhn +qazwsxedca +qazwsxedc12345 +qazwsx1985 +q1w2e3q1w2e3 +q123456789q +q123454321 +pussymonster +purplerain +punknotdead +ptktyjuhfl +programist +prigoda25392637 +prezervativ +prewedpoka +pqowieuryt +pppppppppppp +poseinfopas +polzovatel +polinochka +polina2001 +pleazcufme +pikkunappi9c +pianoforte +pi31415926 +pervertikal +peopleshit +password77 +password2000 +password09 +parrothead +parol12345 +parishilton +paradiddle +pakistan123 +ozymandias +outrageous +oreocookie +optimusprime +ooglyoogly +onetreehill +onedirectio +olegstan1980 +nuttertools1 +nuqyihyz85 +ntktajyxbr +nomorenavy +nokia12345 +nobodyknows +nikolaevich +nikitanikita +nicole1268 +nickisdick +nickcarter +nhjkmnhjkm +nhfvgfvgfv +newfoundland +netbilling +nellynelly +nationwide +nastolatka +nascimento +myfuckingpwd +mybusiness +motherlode123 +monkeyhead +moneypenny +mm070809mm +misha12345 +minasgerais +middlesbrough +michelle78 +michaeljordan +metallica2 +mercedes600 +mementomori +maxavanti1 +matthew123 +mataclsea86 +mastermaster +marley123b +marinecorps +marilynmanson +margaritka +malikmalik +madworld99 +madeinxpain +ltkmnfajhc +lovemetender +lovemebaby +loveisgood +loveandhate +lmp44rgiap +liverpool1892 +lightning1 +lieutenant +lexxityler +lenkapenka +lbyfvjrbtd +landcruiser +kunichka12 +kristinochka +kqq4hthwnv +kotenochek +korolishut +kolokolchik +klop1999666 +kjudbytyrj +kirill1998 +killswitch +killer1996 +keco0gu558 +k92gjdftyv +justinkislyanka +jumper2088 +judaspriest +jkmufjkmuf +jimihendrix +jihgfedcba +jesus0077993 +jellybelly +jellybeans +jacksonville +jackandjill +iwannafuck +irinairina +interpreter +internationa +internaciona +intelligent +innovision +inkvizitor +ingaforptfcor +informatika +informatica +iloveyoutoo +iloveyou10 +ilovemysel +ilovecomics +iloveboobs +iloveallah +ikhouvanjeomer12345 +identificati +i1l2n3u4r5 +huntington +huckleberry +homosapiens +hollidolli +holacomoesta +hjvfyhjvfy +highlanders +hfpheibntkm +hesoyamhesoyam +hesoyam1997 +herjkf33978 +heartbreak +headstrong +harrington +harleydavidson +harley1200 +happygolucky +gygypyfyyyposhy +gthdjghbxbyf +grenouille +greenville +greenlight +grandmother +gothamcity +gonefishin +golovolomka +godzillaslpo +goalkeeper +glittering +gjhneufkbz +giveittome +girlsgirls +ghtlghbznbt +ghjdthjxrf +ghfdfzyjuf +ghbukfitybt +ghbdtngjrf1 +ghbdtnbr12 +ghbdfnbpfwbz1 +ggggggggggg +gfhjkm2010 +gelashvili +gamer4life +fynfhrnblf +fyfcnfcbz09 +funkymonkey +fuckyouall +fuckherface +freeatlast +francisco1 +footballer +football53 +fj6544fjdfj +fhvfutlljy +fedorovich +federation +fatamorgana +ewelina6814838 +everlasting +estrelinha +enterenter +energystar +elenagilbert +ekmnhfvfhby +ehbyjnthfgbz +eeeeeeeeee +dt68468drt +drift89094072877 +dreamtheater +dreamlover +dragonking +dragon6661 +dragon2000 +doroshenko +don'tremember +dnepropetrovsk +dkflbckfdxbr +disksvolumes +dirtydirty +dirtybitch +dirtybastard +dipazara12 +dima081996 +dianadiana +den121255555 +demondemon +demon021295 +defleppard +december25 +dbnfkmtdyf +daydreamer +davidvilla +darlington +daniil2009 +daniel12345 +d36rkqdffd +d2koi82711 +d1e2n3i4s5 +cvthnmdctv +ctvbwdtnbr +cthutqcthutq +ctdthysq777 +ctdthjldbycr +cristianoronaldo +crazybitch +countryboy +corporation +cornflakes +cornerstone +corinthian +complicated +communication +collingwood +cntgfytyrj +cnjvfnjkjubz +ckjyjgjnfv +cjkysirjvjt +chupacabra +chuchundra +chronic420 +christmas1 +christiano +christelle +chrischris +chris12345 +checkinglevel +charles123 +cfvfzcfvfz +cfnfybcns1 +cfifcfifcfif +cbvcbvjnrhjqcz +carpediem69 +capricornio +caperucita +cannonball +candyfloss +california1 +caddyshack +c4jx7u4uoi +buster2000 +bujhmhekbn +buenavista +bubblehead +browniesunda +broadsword +brighteyes +brassmonkey +brasileiro +bountyhunter +bobesponja +bobbybobby +blacksmith +blackrain1 +blackpanther +blackdeath +bkw28hefex3 +bittersweet +birthday11 +bigcountry +bergkamp10 +bellabella +beastmaster +bbbbbb2000 +bassingwell +basshunter +baseball22 +baseball15 +barbershop +balloons99 +bakersfield +atyourbusiness +associazione +ashleysparks +asdfgqwerty +asdfghjklzxcvbnm +asdfghjklzxc +asdfghjkl123456 +arsenal1886 +appleapple +annihilator +angel2726722 +anetov_32541 +andrey1995 +andrey1986 +andrei12345 +allahuakba +alianzalim +alexsandra +aleksandrovna +akunamatata +airtraffic +aiculedssul +ahmad99211 +adrenalin1 +admin12345 +ad211253am +abercrombi +abcdeabcde +aaronfg1987 +aabbccddee +aaaaa11111 +aa123456789 +a758bndnwo +a37289010a +a1a2a3a4a5a6 +a123123123 +yjdjnhjbwr +ver4594gss45 +uninstallper +unattended +startservice +staratagain +setupstartse +setupdicallc +sanynokia5130 +rossi32575 +rhododendron +qq11556666 +physicaldevi +oiv3bh7356 +nitrodude14 +netddedsdm +netfxsbs10 +mypeinfedef +multiprocess +lvbnhbq777 +jrk0jhs584 +installwmp64 +installenume +idechannel +fu082508251 +forester1377 +ensicptfcor +egxkc833sw +directoryser +deletecurren +cthutq1976 +cmdevtgprov +corperfmonex +ab.1234567 +a25802580a +a0c9697d07 +a0c9081ff6 +999999999a +98959636987 +987654321x +9508550818 +9279807412ss +9279807412ddss +9008188523 +8dagycf9ldtf +89636225581 +89514845466a +89223105865 +89185354047 +89081858374 +825forever +80989877991 +80987630910 +80502824904 +80502354942 +80484971237 +80173130022 +7e9lnk3fc01 +789654123a +789456123z +7762fffddd +753951852456 +741852963a +74108520963 +6y7u8i9o0p +6789101112 +666666666666 +6200595tach +5668nitram10 +54321qwert +5432154321 +4rfv5tgb6yhn +4904s677076 +38popugaev +321673216732167 +3141592653 +2wpendalf0 +2pacalypse +2dgdi9wi6t +281299gleb +2468024680 +2465243391 +23vec4rpcc +235101i9ddv +2215334756 +220790140907 +214365jktu +1z2z3z4z5z +1z2x3c4v5b6n +1vfhufhbnrf1 +1qw23er45ty6 +1q1q1q1q1q +199649316zed +198219821982 +148axe5312 +147qwer741 +147896325a +147852369q +147852369a +1478523690 +1379513795 +13579qetuo +123reconect312 +123qwe456asd +123cbybqrbn +123bfg123bfg +123azat654 +123654789q +1234qazwsx +12345qwertasdfg +1234598765 +12345678qw +123456789xxx +123456789rr +123456789qwertyu +123456789ira +123456789abcd +123456789ab +1234567890qwer +1234567890e +1234567890qweasd +1234554321d +123454321q +12344321qaz +123321123q +123123456456 +1230984567 +121314151617 +1197910233 +112233445566778899 +1111100000 +11110000vn +10ytuhbnzn +102030405060708090 +102030102030 +10111964hw +09092009sophie +0908070605 +09001286772 +06081982h@d! +0551144kolya +05364335845 +03224821016 +030169dennis +0147896325you +01020304050 +..lzkihcswipo +~censored~ +zzzzzzzzzzzz +zxcvbnmnbvcxz +zxcvbnmasdfghjkl +zxc123456789 +zx24101989 +zolushka31 +zms7er6nzsr +zcjikfcevf +zcfvfzcfvfz +zaqwsxcde123 +z321654987 +yunx12yunx +ytljgthtgbk +youngandrestless +yjuufyjbuea +yjdsqgfhjk +yjcnhflfvec +yfnfif1978 +ybrbnf2010 +xf6cmqlrpeuhjuvv +xdlig7e8wqz +xkzwnuxkdn9a8kcykuitbkg1g +wsxz3823922163845 +wsxasd1212 +worldoftanks +witchunter +winresponse1 +winniepooh +wilmington +williamson +wildturkey +wilderness +wildcherry +whysoserious +whitewater +whitehorse +werilopert +wednesday1 +webairtest +wbdbkbpfwbz +waterwater +waterhouse +voffka2416 +vladikavkaz +vlad123456 +vkontakteru +vkontakte1 +vjqgfhjkm1 +vjcrdf2011 +vivalavida +vitya12345 +vitalik1994 +virginiatech +violoncello +viktor1992 +viacheslav +vfrcbv1996 +vfrcbv1984 +vfqrhjcjan +vfksirf2409 +vfhbjytnrf2 +vfcnthbvfhufhbnf +verkot492934 +venividivici +vbhjiybxtyrj +vbhjdjpphtybt +vasyavasya +valera1990 +valera1975 +vybo34vp5e +vfwyeuv6aueh +ultraviolet +ukrtelekom +ujujkmvjujkm +ujkjdxtyrj +uhfybn8888 +uchihasaske +tyreckiy11 +typo123321 +tylerjames +twentythree +tutanhamon +trustingod +trueblue123 +trickydick69 +triagrutrika +transporte +trampoline +trailblazers +tlbycndtyysq +timberwolves +tightpussy +the1andonly +tenstoreys3 +tangotango +t7yudb1xmu +switzerland +sweethoney +sweetcheeks +svyatoslav +svpapa1991 +supersonics +supersmash +supersaiyan +superman89 +superman88 +superman21 +supergrass +sunflowers +successfully +streetfighter +strangelove +stoolsample +sterlitamak +stardust49 +starbucks1 +stantheman +splodger12 +spaceballs +southafrica +sonofabitch +somerhalder +smileyface +slonyaka082367 +slavaslava +skywalker1979 +skyscraper +sexsexsexsex +severomorsk +sevenseven +serenitynow +seregaserega +serega1996 +serega1993 +serega1988 +serega12345 +septiembre +sennheiser +selfsigncert +seifenkiste +secretsecret +scrolllock +scoobydoo2 +sasha_10_11 +santabarbara +samsungkoze176351 +sabretooth +s5jdeuz94c +s1a2s3h4a5 +russia18rus +ruslan1991 +roughrider +rosbergnico +romaska123 +romario1985 +rododendron +rocknrolla +roadwarrior +rjylhfntyrj +rjybkbyuec +rjrjifytkm +rjktcybrjd +rjifrpdthm +rjhjktdcndj +rjgtyufuty +richierich +rhjzp59vnq00 +rhfcjnjxrf +rhenjqgthtw +rfrjqgfhjkm +rfrfirfrfrfirf +rfkfiybrjdf +rfhbyfrfhbyf +reybkbyuec +retsamoen123 +restaurant +restart235 +resolution +remotedeskto +relaxwebbewxaler +reiayanami +reanimation +realestate +razvedchik +randalthor +raijmakers313 +ragamuffin +radioshack +qwertyuiopasdfghjklzxcvbnm +qwertyuiop1234 +qwertyhgfdsa +qwerty1488 +qwerty1234567890 +qwertasdfzxc +qwerfdsa123 +qwegta13091990 +quant4307quant4307 +qqwweerrttyy +qqqqqqqqqqqq +qetuoadgjl +qazxswqazxsw +qazxcvbnm1 +qazwsxedcr +qazwsx123edc +qawsedrftgyhujik +q1a2z3w4s5x6 +q12we34rt5 +q0w9e8r7t6 +pussyisgood +publicidad +psychology +psychedelic +professionaltools +privetkakdela +princess69 +powerhouse +portable18 +pornslinger +pornmaster +pornisgood +porfum2007 +poopypants +polisterman +polina1998 +poiuytrewq1 +pj2f6f4pab +pizzapizza +pippopippo +philippine +pfpthrfkmt +peterbuilt +perspektiva +pernambuco +perforator +penetrator +pavelpavel +password999 +password91 +password85 +password55 +password45 +password20 +password18 +password15 +password14 +password07 +pass123456 +paramaribo +paralelepipedo +paracetamol +paperchase +panteleeva +pandapanda +pamelarafter +paladino30 +pakistan786 +paddington +pcrmcfnsoqw+ +p5kplc1600 +overwhelming +oscarwilde +opensaysme +oooooo2000 +oljaivanova +oleg121096 +oksanochka +offthewall +nymphetamine +ntnhflmcvthnb +novusnovus +november18 +noobsaibot +nomommy123 +njxrfrbgtybz +njkmrjlkzvtyz +nikolaenko +nikitapwnz +nikita1991 +nightwatch +nicholas123 +nhtnmzrjdf +nhteujkmybr +nhfycthabyu +nhfycajhvfnjh +newport100 +newcastle1 +neverlands +neverguess +neverforget +nekronomikon +necropolis +nbwnbw258789 +natasha2010 +natasha1994 +natali1987 +nastyalove +nastya1994 +mscormmc11 +motorola123 +motherfucke +mosquito13 +morningstar +moonflower +monkeyballs +moneytalks +moneymaker1 +modernwarfare +mobius4175 +mo.gjkd13o +mnogotochie +mmmmmmmmmmm +mmmmmmm2000 +miyvarxar1 +missionary +mikesr1944 +miguelangel +melloyello +mazefaka2009 +mazafaka07 +mattdoll99 +matiasjerez123 +mastercraft +masterchie +market892892 +mariomario +maplestory +manetalox2 +mandermka112 +mamulechka +makedonija +mainstreet +maharashtra +macpherson +ludicgirls2 +luckycharm2 +lsjkfghkj45kjh +loveyou123 +lovetofuck +loveislove +loveandpeace +lopaslopas +locomotive +lkjhgfdsaq +ljh345ljh3577 +liverpool8 +liverpool10 +littleman1 +littlejerry +literatura +lionking91 +linebacker +lindalinda +likvidator +liaandsonya11 +lexusgs300 +lbyjpfdhbr +lbaathtywbfk +lastresort +labasrytas +la21236393 +la123456789 +kyk1rbk1hbw0uqkckwbh +kthf210599 +kristoffer +kristina2010 +kristina1998 +kostya1996 +kosanostra +konstruktor +kolyakolya +klaviatura +kirill2003 +kirill12345 +kirichenko +kingsland1 +kingarthur +kilpikonna +killthemall +killerkiller +killerik45 +kbytqrfufdyj +kazantip2009 +katherine1 +karina2008 +karina2005 +kapodopera +kapitoshka +kalinkamalinka +k4hvdq9tj9 +jveu9flpa7 +junkyard129 +juliocesar +jshao420788 +journeyman +jesusjesus +jesucristo +jerrygarcia +jasonjason +jamiejamie +jackdaniel +jabbawockeez +iu234jkh2g +interkross +interesting +interaktiv +inteligent +instrument +inoeacc023 +incrediblereview +imperia2010 +immobiliare +ilovetofuck +iloveryan1 +ilovemymother +ilovemykids +ilovemother +ilovekevin +ilovekatie +ilovejosh1 +ilovechris +ilikegirls +ifyawantme +idkfaiddqd +iamtheking +i5stwf1rcx +hurricane1 +hugefuckin +howardstern +hoperzone13 +hoobastank +homosexual +homesweethome +homerhomer +holodilnik +hoffenheim2008 +hockeytown +hgdxtgttgfdtdtes +hfprhbdeirf +hervam1982 +herpderp123 +hellogoodbye +heijeg1231 +hbrbnbrbnfdb +happytimes +happynewyear +halamadrid +hahahahaha +gwiazdeczka +gunfighter +guilherme1 +guanajuato +guadalajar +gryffindor +groundzero +greensboro +gravedigger +granturismo +grandtheftauto +grandegato +government +gorelova2009 +goldinfo1975 +goldengate +godlovesme +gnthjlfrnbkm +gjrtvjygbrfxe +gjpbnbdxbr +gjkbghjgbkty +gjgeufqhjvfyfzif +ghtphtybtcerfv +ghjvjrfirf +ghjcnjcfyz +ghjabkfrnbrf +ghbywgthcbb +ghbyn1ghbyn +ghbyc123654 +ghbrjkmysq +ghbdtnjktu +ghbdtnhecz +gfhjkm12345 +gfhfljrc83 +gfhfktkbuhfvv +gerasimenko +gavrilenko +garrypotter +garoldfake +gabrielle1 +g3ce003389 +fylhtq1234 +fyfnjkbq777 +fyfcnfcbz1 +fybvfcnth07 +funnygirls +fundamental +fuckyourass +fuckyou666 +fuckthisshit +fuckthesystem +fuckingfuck +freshwater +freeloader +frederique +fr6vv5j2hy +foreveralone +fordranger +fordracing +fordfalcon +forcabarca +football69 +football55 +football23 +foofighters +fluminense +flatronlgl +flatronf700p +fkajfhsgt19pot +fieldhockey +fenderstrat +fdnjvfnbrf +fantastika +fank10011996 +familia123 +faisalabad +facefucker +fowtaop572 +exbntkmybwf +evanescenc +enumerated +englishman +encyclopedia +elizabeth2 +elementary +eatmyshorts +ejxjzgbgunaz9z6j6exbniqga +dzxtckfdjdbx +dyskoteka1 +dumbledore +dskscsgslscns +drjynfrnt2 +dragonlady +dragonballs +dragon2010 +donthackme +doggiestyle +dkflbvbhjd +djkujljycr +distortion +dinosaurus +dingleberry +dima324315198540 +dima123456789 +diablo4419 +dfvgbhdfvgbh +dflbvghjrby +dfktynbyjdbx +dfcbkmtdbx +dfasijhads +devils2000 +development +desolation +desertrose +derr6565852 +derevorulez +derbycounty +demilovato +delanoteslik +defjoin123 +defil3dsmdme +dedperdyn1 +dedication +deathdeath +deathangel +deadmanwalking +deadkiller +dbzursitesux +dbyjuhfljdf +david12345 +dasha12345 +darkmanija +danielle12 +damnedworld +cydvbb3qkc +cxfcnkbdsq +cunnilingus +cumsucking +cuckoldroy +ctvyflwfnm +ctdthjvjhcr +cskamoscow +creditcard +crazytrain +crackwhore +crackerjack +copperhead +coolermaster +cookiedough +connecticut +configuratio +compatibility +collective +coffeebean +cockmaster +coastguard +cnfhsqgfhjkm +cm6e7aumn9 +clubstar88 +cloudstrife +cliegaliases +classified +ckjyjgjnfvs +cjustklp6l +cityofangels +cityhunter +cinemabizarre +ciaociao1234 +chocolate12 +chicharito +chester1220 +cheerleade +cheekymonkey +chatterbox +chargers21 +cgjrjqcndbt +cgbhbljyjdf +cfycbnb527 +celinedion +cdtnkfyf20 +cdthlkjdcr +cbhtytdtymrbq +catinthehat +castlevania +caribadive +canterbury +camouflage +callofduty123 +bynthrhjcc +bycnhevtyn +butterscotch +butterball +butlerandsly +burlington +bullmastiff +buenosaires +budakpandai80 +broadband1 +boomersooner +bogdan2010 +bogdan2009 +bloodstone +bloodhound +blood100101 +blacksnake +blackmamba +blackeagle +birthday424 +biotopuser +biochemistry +bigtimerush +bigbarabum +bestseller +best123admin +bellybutton +bellissimo +bellissima +bellebelle +beefcake12 +battletech +bassingwel +bar588bar588 +banane2007 +bananas123 +bamor123456 +bambolotto +baconmania +b7b7vhig5ij5 +b123456789 +awesomeness +avatar2010 +autococker +australian +ashymipoutr +asdfqwerty +asd515wasya +asd123456789 +as9ffz1721 +arshavin23 +arquitectur +armagedon666 +ardeshniki +aqswdefrgt +apocalyptica +antonanton +angelalways +andrey1998 +andrey1980 +andrei1992 +andrade123 +amoreterno +allahisgreat +alexflip28 +alexandrina +alexandre199 +alejandrit +alaagadban +ajhntgbfyj +aezakmiwanrltw +advertising +adrenalin2 +adminconfig +adidas2010 +adidas1221 +aceventura +accountblock +abkbvjyjdf +ab12345678 +aa11223344 +a270ye13rus +a1s2d3f4g5h6j7k8 +a1s1d1f1g1 +a123456789z +a1234554321 +a112233445566 +a0987654321 +zzdkayla13 +zms107sb41 +zealot4life +supercool7 +spudnick006 +slava5sandr5 +setupopeninf +setproductid +setcurrentpr +sedevacante +scesetuproot +readdataport +raoyu1359760 +petruyuiyuf +netmeeting +mychangeserv +mustang222 +mosquito@13 +mercedesw202 +killer32145 +jamaika2010 +iwj7wdq256 +installwmfsd +installutill +initializing +hvergelmir7 +hannah2009 +godverdomme01 +fermazombi +desire3302 +cyngielek1 +cookietime +configuring +communicatio +classdescrip +chiebradrieri +albanycolonie +adomdreadme +9bm555p111 +9999999990 +9876556789 +987654321v +987654321o +987654321n +987654321k +987654321c +963852741a +93869386qq +9208702428 +9200420135 +913a068t34 +9101478595 +9096522353 +9067755344a +89610016824 +89606469196 +89520727541 +89502090940 +89262572372 +89218133426 +89118637920 +89085030336 +89065147454 +88317025885 +80979057356 +80978288796 +80974400365 +80969686976 +8095qq2255 +80955328095 +80951790253 +80679456691 +80673945177 +80672829473 +80666504123 +80665661869 +7x9of5jgfc +7h7yvnhu9l +7b4k9znkew +7946138520 +789456123v +78945612300 +78557855ur +75508369202 +737kkblskv +6ec9049300 +665544qwerty +654321654321 +5656565656 +55cwszsw2l +54321trewq +53433553hjvfy +4f9046r198 +4chotsr734 +48625123789 +47892knjq333 +4512367890 +430-43-034- +409542toxa +3daughters +3rjqdxcnmrc568 +3561864546w +3323433234 +3216549870 +3133731337 +2uef2fl1ee +2ezlgic37h +2daughters +280473181rx +252525qweasd +2468097531 +2342923429 +2298558922 +2233445566 +2121212121 +201020102010 +1z2x3c4v5b6n7m +1t2t3t4t5t +1qazxcde32 +1ni3yax91g +1ndonesian +1f2f3f4f5f +1asdfghjkl +199719971997 +1995lis1995 +19951211as +19684sheperd +192837465a +1597538246 +1597534862 +1597532846 +1533355ddddd +1502197202 +14slava0797 +14881488wpcc +148814881488 +1478963250 +147258369m +14121995vlad +1405932009 +13rhfcyjlfh +1357986420 +1324657980 +123zxc123zxc +123qw456as +123dolchie098 +1236987456 +123654789zx +123654789qwe +123654789fgt +1234qwer1234 +12345a12345 +123456love +123456cfif +123456asdf +123456abcdef +12345678qwertyui +123456789zaq +123456789u +123456789qazwsx +123456789my +123456789lox +123456789lena +123456789kk +123456789ivan +123456789cfif +123456789alex +123456789852 +1234567890t +1234567890qqq +1234567890i +1234567890f +1234567890c +1234567654321 +12345671234567 +12345654321z +1234561234 +123454321a +12341234qw +1234123121 +123321123456 +123123123qwe +1230984756 +12051993mm +11cib22ru33geg +1122334455er +11223344556677889900 +1111155555 +111111slim +11111111112 +11111111111111 +10203040506070 +1000000000 +0p9o8i7u6y +077547596a +071097mama +0547275964 +0526390960 +0508198113 +00212100vova +0000011111 +0000000000a +00000000000 +zzzzzzzzzzz +zzzzz55555 +zxcvbnm12345678 +zxcvbnm1234567 +zxcvbnasdfgh +zxccxz85265493 +zxc123zxc123 +zvezda0307 +zsxdcfvgbh +zs3vs23zs3vs23 +zrhenjqxtk +zolushka71 +zolushka122 +zolushka11 +znakomstva +zeus7169-ladidadi +zdrjynfrnt +zaq12wsxcde34rfv +zapadlobla +zalupa2005 +zaebali123 +ytrhjyjvbrjy +ytrewq123456 +ytgjdnjhbvsq +ytghjcnjnfr +ytekjdbvsq +ytdpkjvftnt +yqpfrn9652 +yjdsqujl2009 +yjdfzcnhfybwf +yesterday1 +yeahyeahyeah +ybrbnfybrbnf +ybrbnf2005 +ybrbajhjdf +yanshi1982a +y6t5r4e3w2q1 +y5hjkdg82490840 +xxxkthf00ru +xxx48562xxx +xeyufxfyuf +x414xatorx +wwwwwwwwwwww +wtkrfgbplf +workcentre +wonderland1 +womble0012 +winston2010 +winniethepooh +willowtree +williams123 +wildwildwest +whitetail1 +whiteshadow +whitekoma212 +whitedragon +whatthefuc +wetpussy69 +webcrackers +webcracker +warhammer40000 +waltdisney +wallflower +wahyqu5u7ena +w987654321w +w2d04183c3 +vyjujhtcjd +vyacheslav +vrotmnenogi +vova244153 +voo8pedoja +volunteers +volumebass +vladimir33 +vladik1998 +vlad0685414688 +vjzvfksirf +vjzltdjxrf +vjqvfktymrbqvbh +vjcrjdcrbq +vjcrdf2010 +viperpeeps +vincecarter +viktor1985 +victoria12 +vfzrjdcrjuj +vfuybnjujhcr +vfrcbvrf123 +vfrcbvjxrf +vfrcbv2010 +vfrcbv2005 +vfibyfvfibyf +veronichka +vekmnbahern +vdohnovenie +vbhfakjhtcn +vasyapupkin +vascello07 +vanhelsing +valera1985 +vaitomanocu +v1a2d3i4m5 +uzumakinaruto +uuuuuu2000 +uthfcbvjdf +user990094 +untouchable +universitet +universida +universalnyj +understandin +underscore +undegraund +ujyxfhtyrj +ujkjdfcnbr +ujkjcjdfybt +uhtxfcvjkjrjv +uhfdbnfwbz +uhbujhmtdf +tvtkmzyjdf +turtlesoup +tttttttttttt +trytologin86 +true7521478957 +triceratops +tranzistor +trance4life +toyotacamry +tourmaline +totototo123 +tonymontana +tomandjerry +toliktolik +tirishkina +thunderdome +thundercats +threatening +thisstrnfs +thisismine +thisiscool +thermaltake +theonlyone +thegunners +thegreatest +thechosen1 +tetragrammaton +testarossa +termenator +temperament +tel3031636 +teddybear1 +techsupport +taylorswift +taylorgang +tasslehoff +taokhongbiet +tanyatanya +tajikistan +tabletennis +syncmaster710n +sydney2000 +sweetwater +sweettooth +sweetness1 +supremalex +superwoman +superman99 +supergummby +superexlax +sunnyleone +success2010 +success123 +sublime420 +student123 +stronganuta +stringbean +strickland +streetracing +stervochka +stephenson +stepanenko +steelers86 +steakout23 +statistics +starostina +stalkerstalker +stalker333 +stalker1995 +stalker007 +ssssssssssss +springtime +sponser123 +spiridonova +spierdalaj +spiderman7 +spectrumsonline +spartakmoscow +spartak2010 +soyelmejor +southside1 +sosca6661254 +solrac1103 +softball13 +softball12 +sofiasofia +soccerstar +soccer2008 +snowflake1 +snooker147 +snapdragon +smokehouse +slipknot12 +slava12345 +slaptazodis +skakalka199218 +simsimopen +simplepass +simbasimba +silverwolf +silverwing +silverfox1 +silverfish +silverchair +sillybilly +shoulders2 +shortstuff +shortround +shopaholic +shestakova +sharkshark +shadowcla1 +severodvinsk +sevenofnine +setmysites +sergey1999 +sergey1995 +sergey1993 +sergey1992 +sergey1986 +sergei1989 +serega58rus +serega2010 +serega1991 +serega1987 +serega0000 +sera19930220 +sephiroth1 +sentimental +senhasenha +senbonzakura +sekret199502 +secretpass +scoobydoo1 +schoolsucks +schastlivaya +schanzen22 +schallapatapan +scaniar620 +saturdaypnb +sasyke1234 +sashka159357 +sanya12345 +santiago123 +santasanta +sanlorenzo +samsung777 +samsung2011 +salvatore1 +salernitana +saintsrow2 +sa15011965 +s1e2r3e4g5a6 +russia1488 +ruslanchik +ruslan2011 +ruslan1992 +ruslan1984 +ruslan12345 +runescape1995 +rubinkazan +rsc512663995 +roybatty08 +ronaldo777 +romko160392 +rollercoaster +rogerroger +rjylhfnmtdf +rjycfknbyu +rjntyjr123 +ringedtits2004 +rhjirftyjn +rfhnjuhfabz +rfhfdfyrfhbyf +rfhbyf2010 +rfgbnfkbyf +rfczgecz11 +retro12345 +researcher +rereirf123 +renaissance +regfcznbyf +redskins21 +recruitment +reanimat321 +readermail +ratbastard +rastafarai +randyorton +rakkausrunot +rajeshwari +rainbow123 +raggamuffin +radioradio +r697965318 +qwertyzxcvbn +qwertyui2000 +qwertyui12 +qwerty2012 +qwerty1994 +qwerty1985 +qwerty1984 +qwerty1981 +qwerty1975 +qwerty123qwerty +qwerty100500 +qwerasdfzxcv1234 +qwerasdf1234 +qwer1234qwer +qweqweqwe1 +qweasdqweasd +qwe123321qwe +qw212121qw +queteimporta +queensland +quarterback +qqqqq11111 +qqq1234567 +qqaazzwwssxx +qazwsx123123 +qaz123qaz123 +q1q1q1q1q1 +q1a1z1w2s2x2 +pussylicke +purposeful1 +purplepower +pumpkinhead +ptybngbnth +proverkarb +prokuratura +programmist +professore +profesional +principessa +princess23 +primadonna +prettywoman +prettygood +preinstall +pravoslavie +pppoe36176 +powerwagon +powerplant +portugalia +pornaccess +poltergeist +polpolorin +polina2011 +polina2010 +polina2007 +polina2003 +polina2002 +poliklinika +pokemon001 +poiuytreza +plhfdcndeqnt +pkownerisz +pjclalamusa +piterpiter +pirojenka77 +pippobaudo +pinkfloyd1 +phenomenon +phztca63f9zu +peyton5243 +petrozavodsk +petro12345 +peterpiper +peternorth1 +personal123 +permissions +perestroyka +pellegrino +pedrolucasmunhoz +peckerwood +pausebreak +patriot123 +password83 +password420 +password29 +password2010 +password2002 +password19 +password02 +parkour123 +parisfrance +papa411368 +paintbrush +psetupinitre +p1vo123456 +p0pmagicwd* +ovchinnikov +ourbusiness +orochimaru1991 +orientepetroler +orangejuice +optionaldirs +opestarxoz +openthedoor +onthefloor +onlyone4-myxworld +omelchenko +oldtrafford +oksana2011 +oksana1995 +oksana1988 +oksana1976 +ohiostate1 +odz1w1rb9t +odessamama +o1o2o3o4o5 +o123456789 +nzuu4pn89j +nuttertool +nurregulle95 +nuey199300 +november23 +november10 +nottelling +not_needed +nosforeverever +northstars +northampton +nooneknows +nomeacuerd +nokiangage +nokia3510i +nobodynobody +noah3042v2 +nizhnekamsk +nistelrooy +nissan1234 +ninjaninja +nineinchnails +nikita2009 +nikita2004 +nikita2003 +nikita1992 +nighttrain +nightstalker +nicholas23 +nicholas17 +nicholas11 +nhfyrdbkbpfnjh +nhfypbcnjh +nhfycajhvfwbz +nhbyflwfnm +neverthele +never32been16 +neogenesis +nederland1 +nbkbvbkbnhzvlbz +natasha1993 +natasha1978 +natasha1971 +natali1985 +nastya1990 +nastya1988 +nastena123 +naruto1997 +naotemsenha +nakedgirls +nves0vk813 +n1234567890 +mysterious +myeyesonly +mycompnetwork +muslim0626461997 +musicislife +mushmouth1 +murattiotti +msjetoledb40 +motorola1234 +motivation +mother1234 +moshatinkor +moonwalker +moonshadow +monticello +montessori +monkeylove +monkeyface +mollymolly +mnogoto4i3 +mnogodeneg +mnbvcxz123 +mmmmmmmmmmmm +mmano088700 +mithrandir +misterzaklady +misterec54 +misericordia +minimaltrust +milkamilka +milenochka +milana2009 +mikeybihon +mightyducks +michelle69 +michael200 +metropoliten +metropolitan +metapasses +meninblack +melvin1234 +melanie003 +meepmeep01 +medvedenko +medalofhonor +mechwarrior +mchedlishvili +mcdonalds1 +maxensam123 +maxbrand56 +maxamillion +mavericks1 +matrosova27 +matriks10o +matkhaumoi +matematicas +masterpopov +masterpiece +masha123456 +masha12345 +martirosyan +martin1960 +marshmellow +marshmallow +marseille13 +marina2000 +marina1996 +marina1986 +marina1967 +marina12345 +marik322424644 +marihyan911 +mariaelena +mariaeduarda +margarita1 +marcoantonio +manojkumar +manofsteel +manikandan +mania231188 +mammapappa +mamkgoind7 +maksimenko +maksim2007 +makarevich +macromedia +m1a2r3i4n5a6 +m128256512 +luxembourg +luvbekki99 +ltybcltybc +ltybc7327857 +ltqcndbntkmyj +ltncrbqvbh +lovelove12 +lovealways +love123456789 +loseyourself +loserface1 +longfellow +lolita1987 +lohikaarme +lkhcrandme +lkbyysqgfhjkm +liverpoolf +liuminghua +lionelmessi +lightspeed +lifeispain +lifeislife +lifegoeson +lfhbyfjcnhjd +lexx1029384756 +letsgomets +letmein2000 +lemuel913110 +latinlover +ladaia2309 +lacedmeone86 +laboratory +l123456789 +kzryzptdf573 +kwiatuszek1 +kvaracxelia +kuznetsova +kuni42kuni42 +kumarkumar +ktyrfgtyrf +krolik2011 +kristina1995 +krasavcheg +kotopups90 +kostya1992 +korovnikov +kolo__1kolo__1 +kokakola123 +knightmare +klsdfj46546 +klmnopop123 +klawiatura +kkhmlkyin6 +kjvjyjcjdf +kislovodsk +kirill2008 +kirill2007 +kirill2000 +kirill1997 +kirill1992 +kirill1991 +kirill1989 +kingofkings +killer2000 +khuljasimsim +khachatryan +kea85axe8w +kbyrbygfhr +kbumat6162 +kbkavgfcabwda +katerina1990 +karmianset +karina2010 +karina2000 +karina1995 +karatel54852 +kapitalina +kalimantan +jxtymghjcnjq +justin123951 +justforyou +juliajulia +jqme1uy488 +joydivision +josemanuel +josecarlos +jonbonjovi +jonathan12 +jnhbwfkjdj +jlyjrkfccybrb +jkbvgbflf80 +jimmyjames +jgpxt7ah3f +jetmir12345 +jesuslovesu +jesusloves +jesseluotonen +jellybean1 +jeetkunedo +jeejee12312 +jeanclaude +jean-pierre +jdxbyybrjd +javaxishvili +janetjackson +jakethedog +jaihanuman +ivan123456 +irjkfyjvth +irina12345 +ipwnroben123 +invitation +intraining +internet12 +internazionale +interested +intercooler +inglaterra +individual +impressive +imjunglist +imagmakr15 +ilyuminnaciya +ilshat2010 +iloveyoux3 +iloveyoupker +iloveyoubabe +iloveyou69 +iloveyou13 +iloveu1314 +ilovemyschool +ilovemoney +ilovemm123 +ilovekelly +ilovejames +ilovefootball +illuminator +ifynfkmghbvv44 +ifgjdfkjdf +icebreaker +ibrahimovic +ibodebest1 +iamthatiam +iamnumber1 +i123456789 +hutchinson +husky_1303 +htutcnhfnehf +htfybvfnjh +houseroad4 +hornybitch +homeschool +hollister2 +hogtieddazed +hjvfytyrj95 +hjvfyjdhjvfyjd +hjccbzxtvgbjy +hiltonptfcor +hfafbkjdyf +hesoyamaezakmi +henweekend +heatoclevaedr36 +healthcare +hbyfntq1986 +haveaniceday +hastler123 +hastings1066 +hastalavista +hassan030894 +hammersmith +halliburton +gymnastics +gwenstefani +gvelesiani +gummybears +guilherme123 +guilherme12 +gthtpfuheprf +gthtgjldsgjldthn +gthajhfnjh +grigorenko +granadaptfcor +granpgranp +goroddorog +gordolee85-mac2olli +gopstop123 +goody2shoes +gonzoopera +goncharova +golovastik +golovanova +goldielocks +gohkokhean +gogogadget +godfather2 +godfather1 +godblessme +gnbwfcxfcnmz +glastonbury +gladiator1 +gkjnybrjdf +gjuhfybxybr +gjrtvjyxbrb +gjkmpjdfntkm1 +gjkbyf2010 +gjkbrkbybrf +gjhjkmgjhjkm +gjgrflehfr +gidravlika +ghtlfyyjcnm +ghtgjlfdfntkm +ghjwdtnfybt +ghjvnjdfhs +ghjktnfhbfn +ghjcrehjyjbl +ghjcnjrdfityj +ghjcnjnfr123 +ghjcnjhjcn +ghjcnjgfhjkm1 +ghjcnbnenr +ghfdjckfdbt +ghbywtccrf +ghbvtytybt +ghbdtncndbt +ghbdtncfif +gfnhbr787898 +gflibqfyutk +gfhjkmghjcnjq +gfhjkmgfhjk +gfhjkm1992 +gfhfwtnfvjk +gfhfvjyjdf +gfh2ec3ybr +gestioneweb +gcvgrvuut5v6er +gbpltwgfhjkm +gbhjugbhju +gangbang01 +galina1964 +galina1957 +gac80896706 +g5h6j7bvh98 +fynjyfynjy +fynjitymrf +fynbrbkkth +fylhtq1996 +fylhtq1971 +funkyfunky +funkadelic +friendsarc +friends123 +friendofasp +frenchfries +freightliner +freeforall +freedom4me +free2rhyme +fredisdead +frankfrank +francheska +francesca1 +fotografia +forgivness +forestgump +fordtrucks +fordmustang +football99 +football98 +football88 +football81 +football79 +football65 +football50 +football44 +football33 +football32 +football18 +football13 +football07 +focusrs2006 +flyeagle75 +fly33angel +fluminense1 +fleurdelis +fktrctq123 +fkmnfdbcnf +fishnchips +fireballxl5 +ffffffffffff +ferrari355 +fellowship +felixthecat +fdnjpfgxfcnb +fdfnfhrf6666 +fcporto74150 +fccgqag134 +faufybcnfy +fatbastard +fadetoblack +expanasion12 +eva30166cp +eusoumaiseu +estudiante +estrellita +eropokereropoker +ereyes4269-ryan1199 +epsonlq100 +epevfrbyfhenj +engine1995ass +employment +empireearth +elwayyawle +elwayelway +elvislives +eljdjkmcndbt +elisabetta +elior20052482 +elektronik +electrician +ekaterinburg +earnhardt3 +eagle1110-stimpy +e8d45a22a4 +e7h5ewevse +e123456789 +dziewczyna +dwers232wds +dust360271212009 +dtcyfghbikf +dsgtylhbdfntkm82 +driversignin +dreamworld +dreamer123 +dragonne74 +dragonfable +drerhgeegehr +douglas123 +douchebag1 +donkeypunch +dominikana +dolphins13 +dogsandcat +dkfcntkbyrjktw +djnrfrnjnfr +djkujuhfl34 +djkjxbtyrj +diskst312002 +directories +dinamo1927 +dimondimon +dimasd1994 +diehard123 +diablolexa +dflbvdflbv +dfktynby1986 +dfdddf45fdgh +desjardins +derekjeter +depression +departament +denis123456 +demonhunter +defender12 +deepspace9 +deathwalker +deathstars +deathblade +deadoralive +dddddddddddd +dctjnkbxyj +dbzmorpheus +dbyjuhfljd +dbyjrehjdf +dbrnjh572202 +daviddavid +dataperfcoun +darkwizard +darkthrone +darkprince +darkangels +danilka123 +danila2000 +dangermouse +dallascowboy +daggermaster +dagestanec +deyh2dc546 +d8cy2h3ssn +d7q91db3tt +d2a3n4i5e6l +d1d2d3d4d5 +d123456789 +cunningham +cthbqrekbr +cska159951 +cshvtxvfhr6 +cs19ha43da +crystal8970 +crosscountry +crjbk7ylxe +craigslist +cpb.hjxyfs +cornhusker +copernicus +coolperson +coolcat101 +convertible +contractor +continental +contentpass +constantino +conjupiter +confidential +coneyhall24 +comemierda +coldfusion +cocosmells +cobradiver +cnhtkmybrjd +cnfkbyuhfl +cloeandzoey +classof2010 +classof201 +clandestino +ckfltymrbq +cjkm14881488 +cjdthityyjctrhtnyj +cirmikzaken +cigarettes +chupachups +chronocross +chloechloe +chittagong +chikenlove +chevrolet1 +cherrytree +cherrycoke +chelsea1905 +cheeseballs +chatouille +charliebrown +chardonnay +changed_due_to_fraud +chandigarh +champion24 +chairchair +cfvjqktyrj +cfirfrfrfirf +cfirfhekbn +cfiektymrf +cbyuekzhyjcnm +cbvathjgjkm +cbkmvfhbkkbjy +cashmoney1 +cardinals1 +caramelito +caramba100500 +capricorn1 +capilla951 +canoncanon +candycandy +can'tremember +campagnolo +calenicno1 +cactusjack +byajhvfwbz +butterfly2 +buttercup1 +bunnybunny +bumerbumer +buhftvdkf2 +budweiser1 +buddybuddy +buceta2007 +bubbles123 +brownsugar +brownbrown01 +brokencyde +britneyspears +brigantina +brandon123 +bostonredsox +bootylicious +bookkeeper +bombardier +boingboing +boilermaker +bogomolova +bodybuilder +blockbuster +blessings888 +blalenakpa +blademaster +bladedancer +blackwidow +blackwater +blackwatch +blackjack21 +blackestnight +blackersha +blackboard +bitchplease +bitchbitch +birthday99 +birthday53 +birthday333 +biomedical +bilbobaggins +bigdaddy69 +bigblackcock +bgfnmzgfgf +bezopasnost +betelgeuse +bestiality +berlusconi +beetlejuice +beastieboys +bbqychu5rt +baura-megapass +basketboll +bartolomeo +bartbartbart +barrelfish +barcelona12 +barabanova +bammargera +ballantines +bajenfans34 +bagautdinova +badboy1234 +babywkasta2 +b74cx6qs7328 +azsxdcfvgbhnjm +avesatanas +automotive +automation +austerlitz +auntarctic +audioslave +auburntigers +atyjvty666 +atsonlinejobs +aspirine123 +aspire5315 +asianpussy +asianlover +asdfghjklz +asdfghjkll +asdfghjkl111 +asdfghj123 +asdasd123123 +asd123fgh456 +as9ffz1712 +artur12345 +artificial +armagedon1 +aristokrat +arinaarina +arhitektor +arcobaleno +archiarchi +aquafina12 +aqswdezake +aprecio123 +apprentice +application +appelsiini +apolinariya +apokalipsa +anulikdenya +antonio123 +antoinette +antibiotik +antarktida +anna123456 +angelofdeath +andrey1996 +andrey1993 +andrey1989 +andrey1975 +andreiandrei +andrei2010 +andrei2000 +andrei1998 +anderson123 +anacristina +americanidol +amdkgb1889 +altamirano +alphaalpha +allthatass +alinamalina +ali.aliev. +alfa123123 +alexandru1 +alexandrov +alexandra12 +alexander23 +alex123456 +aleksandrovich +ahernjdsqcfl +agiljuliandra +aggressive +aggression +agatakristi +afganistan +aerosmith1 +ae57385738 +adventures +administrati +adidas1994 +adidas12345 +adaptation +adamantium +activision +aceofspades +abretesesamo +abramovich +abrakadabr +abprekmnehf +abdurahman +abdulkadir +abcdefg1234 +aaaaaaaaaaaaaa +aaaaa12345 +aa12345678 +a4815162342 +a3531534b413 +a1r2t3e4m5 +a1n2t3o4n5 +a1l2e3n4a5 +a1b2c3d4f5 +a192837465 +a1234567890a +a123454321 +zaq12wsx3edc +zyjwrprscnwv +yfltymrfxgjr0707 +y1tmit1972 +x777261718x +wpwp5625382q +w0a1ni0604de +vvbhfijfi3 +visualbasic +vadimhacker +uixt1od736 +ufa35forever +ue8xqv84cl +topdevice2 +tnasljohxfysa +twoawwozmnm +startspooler +soldatenko20 +slipota1994 +signature673 +setactivepwr +rfewtww5tgregtr +rr231982w111 +rooclyde89 +qefender098 +qa8916820123 +programmer9 +programmable +precompiling +pciide0chann +oracleclient +oyrcuyi581 +montarios77 +minimalinsta +markpnpdevic +lovesthecock +logpidvalues +liverpool5 +leo3703989 +kp9v1ro7lh +killer1989 +kazancev192 +kakawka123 +installwmdm +installpersi +initializati +idedevicep2t +idedevicep1t +iiaiia1962 +hswfhmcvthnb +hellfire1990 +godzilla02 +ghblehjr111 +genovese001 +generating +gbplfnsqgfhjkm1 +gqlsstfhqgwwvqxjx +fuckerman1 +fuckyourbrain13 +fredericia +finishthread +feathertextu +faiwvn8523 +eventlogmess +euq8pvhrnpssdymizqx+ +enterprisese +egew5twt3tgh65y +efbcapa2010 +dynamicupdat +ds6tetsfdsrfdfgd +dh98818600 +dfkmrbhbz1972 +deviceinfose +consumercomm +completing +cnfybckfd1900 +cheguevara1 +callrunoncea +cb3be910b65 +c7130c0406 +c3hfpyteuflftim +b164002202 +arhangel9797 +andrew1o2p3e4n +aleksaleks +alejoyvale +al#ks3!ksj0xx +adminlol1982 +adminscripts +activationre +abramovich1 +aw53641927 +aa00b7b32a +9inchnails +9fczhr6159 +999999999q +987654321s +987654321987654321 +9876543219 +9876543212 +987654321123456789 +987654321123 +9632587410 +9379992929 +9264532213 +9202506371 +9090909090 +9081518431 +9063537462 +90210semka +8ba9klw1ce +89641891702 +89636511322 +89605348757 +89515145435 +89503102616nss +89279981879z +89278246747a +89277216066n +89242468508 +89224371020 +89208012008 +89161352202 +89092816599 +89066703835 +89061576631 +89056288231w +89055254622 +89032068253 +88053023031 +85fg754kl62br853lpq7 +8524567913 +852456753951 +8345886364 +80986677668k +80984146524 +80979534611 +8097166080 +80966800675 +80958753328 +80938381350 +80925819988 +80684485642 +80675276183 +80673908080 +80669451478 +80666744364 +80663156459 +80661052430 +80637029772 +80635711526 +80508253032 +80507655765 +80506721889 +80432433168 +7ghdbwc3mk +789456123k +7852396541 +771512cherniy +74261700027 +741852963s +741852963d +741852741852 +68chevelle +6645213497 +61106061106 +6050picasso +6035560355 +6005548660 +5t6y7u8i9o0p +5891589112 +5678998765 +557788loveaoi +55606120rg +55555fffff +5555566666 +55555555555 +536a731974 +51287ifvfy +5128078228 +4x8cfp6dyi +492487mike +4815162342d +4783915854 +468499cfif +4673252289 +444444444444 +4271philip +415263748596 +3dtoontube +3qa0evx344 +3921010111 +383stroker +380434522575 +36130911cl1 +3540635406 +323475608n +321mnbvcxz +3175671tdutybq +31504244life +31415926535 +3100100994 +2sexy2hottoh2yxes2 +29893536werty +29011984dl +28ros11bri67 +28lytqcgecnz +272270271983 +271157zn06 +27071996pasha +2684526845 +266534597157 +26540292450 +258456789qazzaq5 +2468008642 +24592135io +2442293live +240295yuppi +239r8ymmhq +2324316578 +2323232323 +2310056743 +224909224909 +2108198fox +200120012001 +1xtvg8blew +1w2w3w4w5w +1s2a3s4h5a +1qw23er45t +1qazxsw234 +1qazse4rfvcxz +1q1a1z2w2s2x +1m2m3m4m5m +1jwwzuw55e +1a2s3d4f5g6h7j8k +1xpx66blrumblst6bbus3 +199319931993 +199119911991 +198319831983 +197819781978 +1973468250 +1969camaro +1953london +17pubpathuk +17101993and +1623142323 +159951357753 +1598753246 +159753qwerty +159357258456 +1593572486 +1593572468 +154384958d +15426378zil +1514131211 +1503900qwas +1478963258 +147258369147258369 +14051997sergey +14021992004urban +1374653317652 +1366613666 +12qwaszx34erdfcv +1298612986 +1253912539 +1248163264 +1245789630 +1243568790 +123zxcvbnm +123qwer123 +123qwe321ewq +123qwe123qwe123 +123qaz123qaz +123password +123kill166 +123joker777 +123computer +123war456rus +123789987321 +1237894560 +1236996321 +123698745q +123698745m +12369874123c +12358132134 +1234qweasz +123456roma +123456mama +123456igor +1234567qwer +1234567qaz +1234567aaa +123456789zxcvb +123456789yfcnz +123456789vika +123456789ru +123456789roma +123456789qwertyuiop +123456789mm +123456789max +123456789ma +123456789love +123456789dima +123456789dfgh +123456789den +123456789az +12345678910z +12345678910a +1234567890qq +1234567890n +1234567890anton +12345678909 +1234567890123456789 +123456789- +123456787654321 +1234567809 +12345654321q +1234098756 +123123123e +123045607890 +1123456789 +111122223333 +111111111a +1111111111111111 +10203010203 +10101010ll +100percent +100490100490 +0plkmmklop +0p9o8i7u6y5t +0k1o2v3a4l5e6v7 +0987654321z +0918273645 +09035170147 +0685817823 +0632835141 +053aruspra350 +05101991az +0509228983 +04398004616 +04061963maryan +0363199505 +030166ase90 +021654922r +02041983ua +0123456789z +0102030405da +010203010203 +00rdtsau4leyzunckj121dwm +002543200254 +..byvulwjjrqg +zxcvbnm777 +zxcvbnm123456 +zxcasdqwe12 +zub8111993 +ztn3tourney1 +zrhfcfdbwf +zrhenjqxedfr +zqxwcevrbt +zonnebloem +zolushka15 +zolotareva +zmxncbv123 +zlatopiska +ziablw4u00bq +zhjckfd2010 +zghtrhfcyf +zghjcnjgfifujleyjd +zerkofhell +zcfvfzcxfcnkbdfz +zaqwsxzaqwsx +zaq123456789 +zamorak123 +zahrfn3orqa +z22226270525 +z123456789z +z0mfgwtfuxlol +yyyyyyyyyyyy +yyqkhdm712 +yuliya1992 +yukitommary +ytjcgjhbvsq +ytgjdnjhbvfz +ytdpkjvftim +ytdfitltkj +ytbpdtcnysq +youtube123 +youngpussy +youmustdie +yoshikatsu +yjdsqujl2011 +yjdbqgfhjkm +yfnfkmz1983 +yfnfif1988 +yfnfif1962 +yfnfiekmrf +yfhrjn1992 +yfhenjepevfrb +yfcnzyfcnzyfcnz +yfcntymrf5294 +yellowcard +ybrjkmcrjt +ybrjkftdrf +ybrbnf2003 +ybrbnf2002 +yyxthh8j9k +y1f2c3n4z5 +xzxzzxzxzca +xxy43fh2jb +xxxpasswords +xtremehits +xthntyjr13 +xoma536685 +xfqrjdcrbq +xdkursym89tm +xbnhjlhbdf +wwwww55555 +www123456789 +wwe23015wwe +wuyoukuaile756 +wsh37xz819 +wrathchild +worldpeace +workaholic +wonderfullife +wonderbread +wnanstns2108 +winterburn +windsurfer +winbabywin +willoughby +willian22049 +wilhelmina +whitesnake +whiteknight +whitegirls +whattheheck +westcoast4646 +wertywerty +wellcome1991 +welcomtohall +webmaster1 +webdesigner +weatherman +waterworld +wastewaste +warhamet2014 +warcraft88 +wanjunleow +wallygator +wainwright +wad559523wad +vyjujcgthvs +vvvvvv2000 +vtyltkttdrfrfhf +vtufgfhjkm +vtkj1893vfy +vsebudethorosho +vovaderkach +voroshilin +voluptuous +volume1991 +volgograd34 +voetballen +vmb2079002347 +vladvladvlad +vladivostok2000 +vladimirovich +vladimirov +vladimir123 +vladik1996 +vk12345678 +vjzcnhfybwf +vjybnjhbyu +vjqyjdsqgfhjkm +vjqvfkmxbr +vjltkbhjdfybt +vivalavita +virusvirus +vinivini68 +vinchester +viktoria2010 +viktor2010 +viktor1990 +vikavikavika +viewsonic1 +victoria123 +victoria10 +victorhugo +vicky140585 +vfylfdjirf +vfvjyntyjr +vfvj123456 +vfvbyfljxrf +vfubcnhfnehf +vfrfhjdjktu +vfrcgfyr666 +vfrcbvbkmzy +vfrcbv1988 +vfqkbcfqhec +vfndtqyjhv +vfkbyjdcrfz +vfifbvtldtlm +vfhnmzyjdf +vfhbyf1982 +veterveter +veterinaria +vespertine +versailles +veronika2010 +veronika1998 +veronika123 +veronica11 +vermillion +verdadeiro +ventilador +vekkfufkbtd +vegetables +veeeveruta +vedernikov +vecekmvfyby +vc4z9n6dso +vbycbytzhfnfv +vbrhjdjkyjdrf +vbnhjafyjd +vbifyz1990 +vbifyfnfif +vbhfvbcnby +vbhdjdctvvbht +vasilchenko +vascodagam +vanya12345 +vampire666 +valparaiso +valladolid +valerkolomakin +valeravalera +valera1999 +valentinchoque +valentina18 +vadimvadim +vadim159753 +vacaegalinha +v3ss4neeker1 +v3xdmta9hcsm +v2c47mk7jd +v2ceomj397 +utythfkjdf +utkanos2009 +utjhubtdyf +ut8xuuv25i +upravlenie +untouchables +unreal2004 +universitario +universidade +universal1 +unemployed +underpants +underoath1 +ulrace173rfhgjd +ujvjcznbyf +ujcelfhcndj +ufdyjlfdxbr +udovichenko +uchihamadara +u5j68dq0kve +u41lqxa1rh +typewriter +twilight12 +tweetybird +tutankamon +turtleneck +turtle3000 +turntables +turkmenistan +turbocharged +tuputamadre +ttdlnhttdlnh +trustnoone1 +trustno123 +truskawka1 +trookie101 +tron696969 +troidaysao1 +tristania1 +trinity1331 +trinitrotoluol +trfnthbyf1 +traveker2011_ +transforme +trampampam +trailblazer +trahtenberg +tqjr65gw5j +toyouenjoy +toyotasupra +toyota1993 +totgeliebt +tos27071991 +tornado123 +tomwelling +tomislol123 +tombgimmost +tomate1000 +todopoderos +tobeornottobe +toalet6rensare +tnssanuam786 +tktyftktyf +titsandass +tissot1853 +tinkerbelle +timoshenko +timosha123 +timinator1 +timhortons +timekillerss +tigershark +tigerflame +thunderbir +thunderbay +thrytouille +threedaysgrace +thonma0510 +thomas1960 +thisisfake +thirtythree +thirmadman +thepervert +thenewlife +themonster +themaster1 +thekillers +thegeneral +thedarkness +thearea401 +thanhtrung +texasfight +teshka_poltavka +temporary1 +temple3779 +teddybears +teamhead02 +tdutybq1989 +tdutybq123 +taxuganoman +tatarinova +tarheels23 +tarasbulba +tarakashka +tankcsapda +tanjatanja +tanglewood +tambourine +takemeaway +takeiteasy +t4y1pszw8u +t3485t3485 +synergy101 +syncmaster940n +syncmaster920n +swordmaster +sweetthing +sweetthang +sweetapple +svetlyachok +svetlana1984 +svetlana1980 +svensps606 +susliksuka +suro123456 +surferdude +supertoronja4 +supernatura +superman2010 +supergrosq +supercutekawaii +sunshine02 +sunshine01 +sunfire616 +summerlove +sumertime117 +suh1584dswk9 +sugardaddy +suckmyball +suckmyarma69 +subliminal +subaruforester +stupidshit +stupidgirl +stukjehout12 +stripclublist +strelnikov +streetrace +streetlife +streamline +strawberries +stravinsky +stratovariu +stratosfera +strasbourg +strangerpnb +straightedge +stradivari +stormcloud +stormbringer +stitchpuller +stimpy9102 +stillwater +stil412441 +stevesteve +stepanovna +stefandeflo +steelwheels +steelers99 +steelers36 +steelers10 +stcyg19718 +stas123456 +starwars2000 +starstruck +starsstars +starsailor +starfucker +staoussa15 +stanleycup +stanislava +stalker2033 +stalker2011 +stalker1997 +stalker1986 +stalker1234 +stagecoach +stabilizer +ssssssssssssssss +sssssssssss +ss55ss55ss +squaresoft +squarepants +spiderman5 +spiderman12 +speerwayfi +speedtouch +spatarikanec +spartak777 +sovershenstvo +southside13 +southafric +soulcalibur +sopmyncmymn +sonikblast +sonic12345 +solnche2704 +solidarity +solfixucin +softball18 +sodastereo +sobrenatural +sniperelite +snapeprince +smolinnikita +smokingkills +smashmouth +smallmouth +smallblock +sloneczko1 +slipknot87 +slava6617644 +slava060998 +skyline2580 +skv20111996 +skullcandy +skorobogatova +skip2mylou +sizzlepass +sixshooter +sivkaburka +sircharles +sinhrofazatron +singapore1 +sineglaska +sinamorata +simplicity +silvidvebuio +silverfang +silverdeal +sillysilly +sillyboys1 +shrewsbury +shreveport +shopcoffee +shitface69 +shippudden +shibboleth +shelbybell +sheffield1 +shayad2426 +shavedpussy +sharpshooter +sharo3taylor +shalomshalom +shagadelic +shaforost22 +shadowmaster +shabandaryant +sexybeast1 +sexogratis +seveneleven +setlanguage +sesshomaru +serpimolot +serpentine +sergio1992 +sergio12345 +sergey1997 +sergey1984 +sergey1981 +sergey1970 +sergey1969 +sergey1967 +sergey1306 +sergey12345 +sergey1234 +sergei1992 +serg3216767 +serendipit +serega53983 +serega1998 +serega1997 +serega1990 +serega1989 +serega1981 +september23 +september2 +september11 +sensimilia +seniseviyoru +sekutanaka +seinfeld123 +segedskaa7 +seesaw2431 +sector2814 +secretlove +secret12345 +secondhand +secondgear +seawolves1 +sdjk985dfkjg +scunthorpe +scubascuba +scsenia911911 +screenplay +scorpion77 +scorpion13 +scorpion12 +schorpioen +schildkroet +scarborough +saulhudson +sashasashasasha +sashamasha +sashakotov +sasha1993150205 +sasha123456 +saraswathi +sanyasanya +sanoskesan +sanguinius +sanfrancisc +sandiego619 +samsungr519 +samsungc3050 +samsung2009 +samsung2000 +samsung12345 +samsung1234 +sampaguita +samoylenko +samireliyev +sambosambo +samara2010 +salomsalom1 +sakeris123 +sagsimpson +sagnintendo +sagnapster +sagminimoni +sagbinladen +sadfsfaasd123 +sabrino4ka diff --git a/app/lib/core/config/auth_config.dart b/app/lib/core/config/auth_config.dart new file mode 100644 index 0000000..005b1bc --- /dev/null +++ b/app/lib/core/config/auth_config.dart @@ -0,0 +1,9 @@ +/// Auth settings the client has to know. They mirror the server configuration. +abstract final class AuthConfig { + /// Validity of confirmation and reset links (AUTH-CFG-07, + /// `GOTRUE_MAILER_OTP_EXP=3600`, see `docs/auth-konfiguration.md`). + /// + /// The text `authCheckEmailValidityOneHour` is only correct while this is + /// one hour; a test pins the value. + static const linkValidity = Duration(hours: 1); +} diff --git a/app/lib/core/licenses/third_party_licenses.dart b/app/lib/core/licenses/third_party_licenses.dart new file mode 100644 index 0000000..6ad7d2e --- /dev/null +++ b/app/lib/core/licenses/third_party_licenses.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Asset with the license texts of bundled third-party material. +const thirdPartyAsset = 'THIRD_PARTY.md'; + +/// Package name under which the entry appears in `showLicensePage`. +const thirdPartyLicensePackage = 'SecLists (common passwords list)'; + +/// Registers the license text of the bundled password list (SecLists, MIT) in +/// the [LicenseRegistry], so that it shows up in `showLicensePage`. The text +/// is read lazily from [thirdPartyAsset] when the license page opens. +void registerThirdPartyLicenses({AssetBundle? bundle}) { + LicenseRegistry.addLicense(() async* { + final text = await (bundle ?? rootBundle).loadString(thirdPartyAsset); + yield LicenseEntryWithLineBreaks([thirdPartyLicensePackage], text); + }); +} diff --git a/app/lib/core/platform/io_error.dart b/app/lib/core/platform/io_error.dart new file mode 100644 index 0000000..75489ca --- /dev/null +++ b/app/lib/core/platform/io_error.dart @@ -0,0 +1,6 @@ +library; + +/// Detects `dart:io` network errors without importing `dart:io` on the web +/// (conditional import). Type checks with `is` keep working in minified +/// dart2js builds, unlike `runtimeType.toString()`. +export 'io_error_stub.dart' if (dart.library.io) 'io_error_io.dart'; diff --git a/app/lib/core/platform/io_error_io.dart b/app/lib/core/platform/io_error_io.dart new file mode 100644 index 0000000..6787046 --- /dev/null +++ b/app/lib/core/platform/io_error_io.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +/// Network errors of `dart:io` (no connection, DNS, TLS handshake). +bool isIoNetworkError(Object error) => + error is SocketException || + error is HandshakeException || + error is TlsException; diff --git a/app/lib/core/platform/io_error_stub.dart b/app/lib/core/platform/io_error_stub.dart new file mode 100644 index 0000000..03d657e --- /dev/null +++ b/app/lib/core/platform/io_error_stub.dart @@ -0,0 +1,2 @@ +/// Web and other platforms without `dart:io`: no `dart:io` network errors. +bool isIoNetworkError(Object error) => false; diff --git a/app/lib/core/platform/platform_info.dart b/app/lib/core/platform/platform_info.dart new file mode 100644 index 0000000..f2dd401 --- /dev/null +++ b/app/lib/core/platform/platform_info.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'platform_info.g.dart'; + +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. +@Riverpod(keepAlive: true) +bool isMobileWeb(Ref ref) => + kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); diff --git a/app/lib/core/platform/platform_info.g.dart b/app/lib/core/platform/platform_info.g.dart new file mode 100644 index 0000000..93751fa --- /dev/null +++ b/app/lib/core/platform/platform_info.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'platform_info.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. + +@ProviderFor(isMobileWeb) +final isMobileWebProvider = IsMobileWebProvider._(); + +/// `true` for the web app in a mobile browser (iOS or Android user agent). +/// +/// Kept in one provider so that platform checks are not scattered over the +/// UI; tests override it. + +final class IsMobileWebProvider extends $FunctionalProvider + with $Provider { + /// `true` for the web app in a mobile browser (iOS or Android user agent). + /// + /// Kept in one provider so that platform checks are not scattered over the + /// UI; tests override it. + IsMobileWebProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isMobileWebProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isMobileWebHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return isMobileWeb(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$isMobileWebHash() => r'd287a0a13cb71abe06b3d01885723f1d3ac520b1'; diff --git a/app/lib/core/platform/url_opener.dart b/app/lib/core/platform/url_opener.dart new file mode 100644 index 0000000..aecef0a --- /dev/null +++ b/app/lib/core/platform/url_opener.dart @@ -0,0 +1,38 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'url_opener.g.dart'; + +/// Opens [uri] outside the app (browser). Returns whether it worked. +typedef UrlOpener = Future Function(Uri uri); + +final _log = AppLogger('UrlOpener'); + +/// Only `http`/`https` URLs with a host may leave the app (no `javascript:`, +/// `file:`, `intent:` and the like). The check lives here so that no caller +/// can forget it. +bool isOpenableExternalUrl(Uri uri) => + (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; + +/// Wraps [launch] with the scheme check of [isOpenableExternalUrl] and error +/// handling. Public for tests. +UrlOpener guardedUrlOpener(Future Function(Uri uri) launch) => + (uri) async { + if (!isOpenableExternalUrl(uri)) { + _log.warning('refused to open url with scheme "${uri.scheme}"'); + return false; + } + try { + return await launch(uri); + } on Object catch (error) { + _log.warning('launchUrl failed: ${error.runtimeType}'); + return false; + } + }; + +/// The only place that touches `url_launcher`. Tests override this provider. +@Riverpod(keepAlive: true) +UrlOpener urlOpener(Ref ref) => guardedUrlOpener( + (uri) => launchUrl(uri, mode: LaunchMode.externalApplication), +); diff --git a/app/lib/core/platform/url_opener.g.dart b/app/lib/core/platform/url_opener.g.dart new file mode 100644 index 0000000..d4a4f55 --- /dev/null +++ b/app/lib/core/platform/url_opener.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'url_opener.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// The only place that touches `url_launcher`. Tests override this provider. + +@ProviderFor(urlOpener) +final urlOpenerProvider = UrlOpenerProvider._(); + +/// The only place that touches `url_launcher`. Tests override this provider. + +final class UrlOpenerProvider + extends $FunctionalProvider + with $Provider { + /// The only place that touches `url_launcher`. Tests override this provider. + UrlOpenerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'urlOpenerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$urlOpenerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + UrlOpener create(Ref ref) { + return urlOpener(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UrlOpener value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$urlOpenerHash() => r'e86eb081c587907d3c162ed619b42abbbfb195a5'; diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 6a1991d..1d279ed 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -3,6 +3,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shuffle/core/routing/app_routes.dart'; import 'package:shuffle/core/routing/not_found_screen.dart'; import 'package:shuffle/core/routing/redirect_source.dart'; +import 'package:shuffle/core/routing/volatile_extra_codec.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; import 'package:shuffle/features/home/presentation/home_screen.dart'; part 'app_router.g.dart'; @@ -13,6 +15,7 @@ GoRouter appRouter(Ref ref) { final router = GoRouter( initialLocation: AppRoutes.home, + extraCodec: const VolatileExtraCodec(), refreshListenable: source.changes, redirect: (context, state) => source.redirect(state), errorBuilder: (context, state) => const NotFoundScreen(), @@ -21,6 +24,7 @@ GoRouter appRouter(Ref ref) { path: AppRoutes.home, builder: (context, state) => const HomeScreen(), ), + ...authRoutes, ], ); ref.onDispose(router.dispose); diff --git a/app/lib/core/routing/app_router.g.dart b/app/lib/core/routing/app_router.g.dart index 40c8d4a..34d5139 100644 --- a/app/lib/core/routing/app_router.g.dart +++ b/app/lib/core/routing/app_router.g.dart @@ -48,4 +48,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'c3ba8cecc011ab4327ceb2284a58118355176803'; +String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9'; diff --git a/app/lib/core/routing/app_routes.dart b/app/lib/core/routing/app_routes.dart index bc31dc0..9aa40c9 100644 --- a/app/lib/core/routing/app_routes.dart +++ b/app/lib/core/routing/app_routes.dart @@ -1,4 +1,16 @@ /// Pfade der App. Weitere Routen kommen mit den Features. abstract final class AppRoutes { static const home = '/'; + + // Auth (Spec #1 § 5.3) + static const login = '/login'; + static const register = '/register'; + static const registerCheckEmail = '/register/check-email'; + static const forgotPassword = '/forgot-password'; + static const authConfirm = '/auth/confirm'; + static const resetPassword = '/reset-password'; + + /// Query-Parameter der Anmeldeseite: Pfad, zu dem nach dem Login + /// zurückgekehrt wird. + static const fromParam = 'from'; } diff --git a/app/lib/core/routing/volatile_extra_codec.dart b/app/lib/core/routing/volatile_extra_codec.dart new file mode 100644 index 0000000..7169a38 --- /dev/null +++ b/app/lib/core/routing/volatile_extra_codec.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +/// `extraCodec` for go_router that does not persist `extra`. +/// +/// go_router 18 JSON-encodes `extra` into the route information, and on the +/// web into the browser history state (`RouteMatchListCodec`, +/// `match.dart`). Data that must not outlive the page (here: the e-mail +/// address on "check your inbox") would stay in the history entry. With this +/// codec `extra` works in memory during navigation, but is dropped when the +/// state is serialized (reload, restoring from history). +class VolatileExtraCodec extends Codec { + const VolatileExtraCodec(); + + @override + Converter get encoder => const _DropConverter(); + + @override + Converter get decoder => const _DropConverter(); +} + +class _DropConverter extends Converter { + const _DropConverter(); + + @override + Object? convert(Object? input) => null; +} diff --git a/app/lib/core/widgets/app_banner.dart b/app/lib/core/widgets/app_banner.dart new file mode 100644 index 0000000..9860f85 --- /dev/null +++ b/app/lib/core/widgets/app_banner.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +enum AppBannerType { info, error } + +/// Inline message banner (info / error) above forms. +/// +/// Type is conveyed by icon + [typeLabel] (spoken prefix, e.g. "Error"), +/// never by color alone. The message is a live region, so screen readers +/// announce it when it appears. The optional action is a separate 48 dp text +/// button. All texts come from the caller (l10n). +class AppBanner extends StatelessWidget { + const AppBanner({ + required this.type, + required this.message, + required this.typeLabel, + super.key, + this.actionLabel, + this.onAction, + }) : assert( + (actionLabel == null) == (onAction == null), + 'actionLabel and onAction belong together', + ); + + final AppBannerType type; + final String message; + final String typeLabel; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isError = type == AppBannerType.error; + final background = isError + ? scheme.errorContainer + : scheme.secondaryContainer; + final foreground = isError + ? scheme.onErrorContainer + : scheme.onSecondaryContainer; + + return DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: AppRadius.mdAll, + border: Border.all(color: foreground.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + liveRegion: true, + label: '$typeLabel: $message', + child: ExcludeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.info_outline, + color: foreground, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + if (actionLabel != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + onPressed: onAction, + style: TextButton.styleFrom(foregroundColor: foreground), + child: Text(actionLabel!), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/core/widgets/app_checkbox_field.dart b/app/lib/core/widgets/app_checkbox_field.dart new file mode 100644 index 0000000..3a0861d --- /dev/null +++ b/app/lib/core/widgets/app_checkbox_field.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; + +/// A piece of a checkbox label: plain text or a link. +@immutable +class AppLabelSegment { + const AppLabelSegment.text(this.text) : onTap = null; + + /// A link. It is focusable via keyboard and exposed to screen readers as its + /// own semantics node (`link: true`) with [text] as label. + const AppLabelSegment.link(this.text, this.onTap); + + final String text; + final VoidCallback? onTap; + + bool get isLink => onTap != null; +} + +/// Checkbox with a label (optionally with embedded links) and error state. +/// +/// The whole row (checkbox + label) toggles the value and is at least 48 dp +/// high. Links inside the label open their target instead of toggling. +/// [errorText] is shown with icon below the row and announced as live region. +class AppCheckboxField extends StatelessWidget { + AppCheckboxField({ + required this.value, + required this.onChanged, + required String label, + super.key, + this.errorText, + this.enabled = true, + }) : segments = [AppLabelSegment.text(label)]; + + const AppCheckboxField.rich({ + required this.value, + required this.onChanged, + required this.segments, + super.key, + this.errorText, + this.enabled = true, + }); + + final bool value; + final ValueChanged? onChanged; + final List segments; + final String? errorText; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasError = errorText != null && errorText!.isNotEmpty; + final interactive = enabled && onChanged != null; + final labelStyle = theme.textTheme.bodyLarge!; + final linkStyle = labelStyle.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ); + + final hasLinks = segments.any((s) => s.isLink); + final plainLabel = segments.map((s) => s.text).join(); + + final label = Text.rich( + TextSpan( + style: labelStyle, + children: [ + for (final s in segments) + if (s.isLink) + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _InlineLink( + text: s.text, + style: linkStyle, + onTap: s.onTap!, + ), + ) + else + // With links the text stays in the semantics tree (the links are its + // children) but is read via the checkbox label, not twice. + TextSpan(text: s.text, semanticsLabel: hasLinks ? '' : null), + ], + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + checked: value, + label: plainLabel, + enabled: interactive, + onTap: interactive ? () => onChanged!(!value) : null, + child: InkWell( + canRequestFocus: false, + excludeFromSemantics: true, + borderRadius: AppRadius.smAll, + onTap: interactive ? () => onChanged!(!value) : null, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSpacing.minTouchTarget, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Checkbox( + value: value, + isError: hasError, + onChanged: interactive + ? (v) => onChanged!(v ?? false) + : null, + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: label, + ), + ), + ], + ), + ), + ), + ), + if (hasError) + Padding( + padding: const EdgeInsets.only( + left: AppSpacing.md, + bottom: AppSpacing.xs, + ), + child: AppFieldError(errorText!), + ), + ], + ); + } +} + +class _InlineLink extends StatelessWidget { + const _InlineLink({ + required this.text, + required this.style, + required this.onTap, + }); + + final String text; + final TextStyle style; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // Own semantics node (`link: true`), so that screen readers reach the + // link on the web, too (custom actions are not mapped to ARIA there). + return Semantics( + container: true, + link: true, + label: text, + onTap: onTap, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm / 2), + focusColor: Theme.of(context).colorScheme.primary + .withValues(alpha: 0.2), + child: Text(text, style: style), + ), + ); + } +} diff --git a/app/lib/core/widgets/app_field_error.dart b/app/lib/core/widgets/app_field_error.dart new file mode 100644 index 0000000..95c3b61 --- /dev/null +++ b/app/lib/core/widgets/app_field_error.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Inline error message below a form control. +/// +/// Information is never conveyed by color alone: icon + text. The text is a +/// polite live region, so screen readers announce it when it appears. +/// +/// With [live] set to `false` the message stays visible and readable but is +/// not announced on its own. Use it for messages that change on every +/// keystroke (e.g. "n characters missing") and switch to `true` when the +/// field is left. +class AppFieldError extends StatelessWidget { + const AppFieldError(this.text, {super.key, this.live = true}); + + final String text; + final bool live; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.error; + return Semantics( + container: true, + liveRegion: live, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.xxs), + child: Icon(Icons.error_outline, size: 16, color: color), + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/core/widgets/app_form_scaffold.dart b/app/lib/core/widgets/app_form_scaffold.dart new file mode 100644 index 0000000..69fd60a --- /dev/null +++ b/app/lib/core/widgets/app_form_scaffold.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Page frame for form screens (login, register, ...). +/// +/// Compact (< 600 dp): full-bleed, scrollable. From 600 dp: content in a +/// centered card of at most [maxWidth] (default +/// [AppBreakpoints.authCardMaxWidth]). The body scrolls, the keyboard pushes +/// the content up (Scaffold resizes) and drags dismiss the keyboard. +class AppFormScaffold extends StatelessWidget { + const AppFormScaffold({ + required this.child, + super.key, + this.appBar, + this.maxWidth = AppBreakpoints.authCardMaxWidth, + }); + + final Widget child; + final PreferredSizeWidget? appBar; + final double maxWidth; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: appBar, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final carded = width >= AppBreakpoints.medium; + final content = carded + ? Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: child, + ), + ) + : child; + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: AppSpacing.pagePadding(width), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.lg * 2, + ), + child: Align( + alignment: carded ? Alignment.center : Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: carded ? maxWidth : double.infinity, + ), + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: SizedBox(width: double.infinity, child: content), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/core/widgets/app_password_field.dart b/app/lib/core/widgets/app_password_field.dart new file mode 100644 index 0000000..4e68505 --- /dev/null +++ b/app/lib/core/widgets/app_password_field.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; + +/// Password field with visibility toggle, rule hint and autofill hints. +/// +/// The toggle is a 48 dp icon button with a tooltip/semantics label +/// ([showPasswordLabel] / [hidePasswordLabel], l10n from the caller). The +/// field is obscured initially. Use [isNewPassword] on register/reset so +/// password managers offer to generate a strong password. +class AppPasswordField extends StatefulWidget { + const AppPasswordField({ + required this.label, + required this.showPasswordLabel, + required this.hidePasswordLabel, + super.key, + this.controller, + this.focusNode, + this.ruleHint, + this.errorText, + this.liveError = true, + this.isNewPassword = false, + this.autofillHints, + this.textInputAction, + this.enabled = true, + this.onChanged, + this.onSubmitted, + this.onEditingComplete, + }); + + final String label; + final String showPasswordLabel; + final String hidePasswordLabel; + final TextEditingController? controller; + final FocusNode? focusNode; + + /// Rule hint (e.g. "at least 10 characters"), shown while there is no error. + final String? ruleHint; + final String? errorText; + + /// See [AppTextField.liveError]. + final bool liveError; + final bool isNewPassword; + + /// Overrides the default hints (`password` / `newPassword`). + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final bool enabled; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onEditingComplete; + + @override + State createState() => _AppPasswordFieldState(); +} + +class _AppPasswordFieldState extends State { + bool _obscured = true; + + @override + Widget build(BuildContext context) { + return AppTextField( + label: widget.label, + controller: widget.controller, + focusNode: widget.focusNode, + helperText: widget.ruleHint, + errorText: widget.errorText, + liveError: widget.liveError, + obscureText: _obscured, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: widget.textInputAction, + enabled: widget.enabled, + autofillHints: + widget.autofillHints ?? + [ + if (widget.isNewPassword) + AutofillHints.newPassword + else + AutofillHints.password, + ], + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onEditingComplete: widget.onEditingComplete, + suffixIcon: IconButton( + tooltip: _obscured + ? widget.showPasswordLabel + : widget.hidePasswordLabel, + icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off), + onPressed: widget.enabled + ? () => setState(() => _obscured = !_obscured) + : null, + ), + ); + } +} diff --git a/app/lib/core/widgets/app_primary_button.dart b/app/lib/core/widgets/app_primary_button.dart new file mode 100644 index 0000000..dce08a7 --- /dev/null +++ b/app/lib/core/widgets/app_primary_button.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Primary action button with loading state. +/// +/// While [isLoading] the button is disabled (no double submit) and shows a +/// spinner next to the label. [loadingLabel] is the spoken label during +/// loading (l10n from the caller, e.g. "Registering..."); falls back to +/// [label]. Full width by default ([expand]); at least 48 dp high, grows with +/// the text scale. +class AppPrimaryButton extends StatelessWidget { + const AppPrimaryButton({ + required this.label, + required this.onPressed, + super.key, + this.isLoading = false, + this.loadingLabel, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final bool isLoading; + final String? loadingLabel; + final IconData? icon; + final bool expand; + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null && !isLoading; + final scheme = Theme.of(context).colorScheme; + final leading = isLoading + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ) + : icon != null + ? Icon(icon, size: 20) + : null; + + final button = FilledButton( + onPressed: enabled ? onPressed : null, + child: Row( + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null) ...[ + leading, + const SizedBox(width: AppSpacing.sm), + ], + Flexible(child: Text(label, textAlign: TextAlign.center)), + ], + ), + ); + + return Semantics( + button: true, + enabled: enabled, + label: isLoading ? (loadingLabel ?? label) : label, + excludeSemantics: true, + onTap: enabled ? onPressed : null, + child: expand ? SizedBox(width: double.infinity, child: button) : button, + ); + } +} diff --git a/app/lib/core/widgets/app_text_field.dart b/app/lib/core/widgets/app_text_field.dart new file mode 100644 index 0000000..18d8898 --- /dev/null +++ b/app/lib/core/widgets/app_text_field.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; + +/// Single-line text field of the design system. +/// +/// The [label] is always visible above the field and wraps (no clipping at +/// 200 % text scale). [errorText] replaces [helperText], is shown with icon +/// and error border and is announced as a live region. All strings come from +/// the caller (l10n). +class AppTextField extends StatelessWidget { + const AppTextField({ + required this.label, + super.key, + this.controller, + this.focusNode, + this.helperText, + this.errorText, + this.liveError = true, + this.keyboardType, + this.textInputAction, + this.autofillHints, + this.textCapitalization = TextCapitalization.none, + this.autocorrect = true, + this.enableSuggestions = true, + this.obscureText = false, + this.suffixIcon, + this.maxLength, + this.showCounter = false, + this.counterText, + this.inputFormatters, + this.enabled = true, + this.autofocus = false, + this.onChanged, + this.onSubmitted, + this.onEditingComplete, + }); + + final String label; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? helperText; + final String? errorText; + + /// Whether [errorText] is announced as a live region. Set to `false` while + /// the user is typing and the message changes with every keystroke. + final bool liveError; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final Iterable? autofillHints; + final TextCapitalization textCapitalization; + final bool autocorrect; + final bool enableSuggestions; + final bool obscureText; + + /// Trailing widget inside the field (e.g. the password visibility toggle). + final Widget? suffixIcon; + + /// Soft limit: input beyond it is not blocked, so the caller can show the + /// error and keep the user's input. + final int? maxLength; + + /// Shows a `n/max` counter (needs [maxLength]). + final bool showCounter; + + /// Optional text for the counter (e.g. "4/30"). Use it when the counted + /// unit differs from the default (graphemes of the raw text). Only shown + /// when [showCounter] is set. + final String? counterText; + final List? inputFormatters; + final bool enabled; + final bool autofocus; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onEditingComplete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasError = errorText != null && errorText!.isNotEmpty; + final helper = helperText; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + Semantics( + label: label, + child: TextField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + autofocus: autofocus, + keyboardType: keyboardType, + textInputAction: textInputAction, + autofillHints: autofillHints, + textCapitalization: textCapitalization, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + obscureText: obscureText, + inputFormatters: inputFormatters, + maxLength: maxLength, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: showCounter && maxLength != null + ? null + : ( + _, { + required currentLength, + required isFocused, + maxLength, + }) => null, + onChanged: onChanged, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + decoration: InputDecoration( + suffixIcon: suffixIcon, + counterText: showCounter ? counterText : null, + error: hasError + ? AppFieldError(errorText!, live: liveError) + : null, + helper: !hasError && helper != null && helper.isNotEmpty + ? Text( + helper, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/auth/data/asset_common_password_list.dart b/app/lib/features/auth/data/asset_common_password_list.dart new file mode 100644 index 0000000..a718959 --- /dev/null +++ b/app/lib/features/auth/data/asset_common_password_list.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; + +/// Lädt `assets/security/common-passwords.txt` (Herkunft: +/// `assets/security/README.md`). +class AssetCommonPasswordList { + const AssetCommonPasswordList({this.bundle}); + + static const assetPath = 'assets/security/common-passwords.txt'; + + final AssetBundle? bundle; + + /// Liest und parst die Datei. Der Aufrufer lädt lazy (erster Zugriff auf ein + /// Passwortfeld) und hält das Ergebnis. + Future load() async { + final text = await (bundle ?? rootBundle).loadString(assetPath); + return parseCommonPasswords(text); + } +} + +/// Zerlegt den Dateiinhalt: eine Zeile je Eintrag, Kleinschreibung, getrimmt, +/// leere Zeilen und `#`-Kommentare entfallen. +CommonPasswordList parseCommonPasswords(String text) { + final entries = {}; + for (final line in text.split('\n')) { + final entry = line.trim().toLowerCase(); + if (entry.isNotEmpty && !entry.startsWith('#')) { + entries.add(entry); + } + } + return SetCommonPasswordList(entries); +} diff --git a/app/lib/features/auth/data/auth_failure_mapper.dart b/app/lib/features/auth/data/auth_failure_mapper.dart new file mode 100644 index 0000000..6b2398b --- /dev/null +++ b/app/lib/features/auth/data/auth_failure_mapper.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:shuffle/core/platform/io_error.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Aufruf, bei dem ein Fehler auftrat. Manche Zuordnungen hängen davon ab +/// (z. B. HTTP 500 nur beim `signUp`). +enum AuthOperation { + signUp, + signIn, + resend, + requestPasswordReset, + verifyLink, + updatePassword, +} + +/// Übersetzt Ausnahmen von `supabase_flutter` in [AuthFailure] +/// (Mapping-Tabelle: Spec #1 § 4.4). +/// +/// Befund zu `gotrue` 2.27.3 (A1-Prüfung der Spec): Jede Antwort mit Status +/// >= 500 wird als [AuthRetryableFetchException] geworfen, mit dem rohen Body +/// in `message`, dem Status in `statusCode` und `code == null`. Ein +/// Trigger-Fehler beim `signUp` (HTTP 500) ist deshalb nur am Status +/// erkennbar; die Prüfung muss vor dem Netzwerkfall stehen. +AuthFailure mapAuthError(Object error, AuthOperation operation) { + if (error is AuthWeakPasswordException) { + return AuthFailure.weakPassword(_weakReasons(error.reasons)); + } + if (error is AuthRetryableFetchException) { + if (operation == AuthOperation.signUp && error.statusCode == '500') { + return AuthFailure.registrationRejected( + reason: registrationRejectionReasonFrom(error.message), + ); + } + // Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall). + return const AuthFailure.network(); + } + if (error is AuthSessionMissingException) { + return const AuthFailure.unexpected(); + } + if (error is AuthException) { + return _mapApiError(error, operation); + } + if (error is TimeoutException || _isTransportError(error)) { + return const AuthFailure.network(); + } + return const AuthFailure.unexpected(); +} + +AuthFailure _mapApiError(AuthException error, AuthOperation operation) { + switch (error.code) { + case 'invalid_credentials': + return const AuthFailure.invalidCredentials(); + case 'email_not_confirmed': + return const AuthFailure.emailNotConfirmed(); + case 'over_email_send_rate_limit': + return switch (operation) { + AuthOperation.signUp || + AuthOperation.requestPasswordReset || + AuthOperation.resend => const AuthFailure.emailSendLimited(), + _ => const AuthFailure.rateLimited(), + }; + case 'over_request_rate_limit': + return const AuthFailure.rateLimited(); + case 'weak_password': + return const AuthFailure.weakPassword([]); + case 'same_password': + return const AuthFailure.samePassword(); + case 'otp_expired': + return const AuthFailure.linkInvalidOrExpired(); + case 'email_address_invalid' || 'validation_failed': + return const AuthFailure.invalidEmail(); + } + if (error.statusCode == '429') { + return const AuthFailure.rateLimited(); + } + if (operation == AuthOperation.verifyLink && error.statusCode == '403') { + return const AuthFailure.linkInvalidOrExpired(); + } + return const AuthFailure.unexpected(); +} + +List _weakReasons(List reasons) => [ + for (final reason in reasons) + switch (reason) { + 'length' => WeakPasswordReason.length, + 'characters' => WeakPasswordReason.characters, + 'pwned' => WeakPasswordReason.pwned, + _ => WeakPasswordReason.unknown, + }, +]; + +/// Liest den Grund aus der Trigger-Meldung (Body des HTTP-500). Best effort, +/// das Format ist nicht zugesichert. +RegistrationRejectionReason registrationRejectionReasonFrom(String message) { + if (message.contains('display_name_invalid')) { + return RegistrationRejectionReason.displayName; + } + if (message.contains('age_confirmation_required')) { + return RegistrationRejectionReason.ageConfirmation; + } + if (message.contains('terms_acceptance_required')) { + return RegistrationRejectionReason.termsAcceptance; + } + if (message.contains('terms_version_required')) { + return RegistrationRejectionReason.termsVersion; + } + return RegistrationRejectionReason.unknown; +} + +/// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise +/// schon selbst; das hier ist die Rückfallebene. Erkennung per `is` +/// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten +/// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js. +bool _isTransportError(Object error) => + error is http.ClientException || isIoNetworkError(error); diff --git a/app/lib/features/auth/data/auth_status_tracker.dart b/app/lib/features/auth/data/auth_status_tracker.dart new file mode 100644 index 0000000..397c261 --- /dev/null +++ b/app/lib/features/auth/data/auth_status_tracker.dart @@ -0,0 +1,47 @@ +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +// `AuthUser` heißt auch in supabase_flutter; hier ist die Domänenklasse gemeint. +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; + +/// Bildet `AuthChangeEvent`s auf [AuthStatus] ab (Spec #1 § 5.5). +/// +/// Abweichung von der Wortlaut-Zuordnung in § 5.5: Die Recovery bleibt +/// bestehen, bis `userUpdated` (Passwort geändert) oder `signedOut` eintrifft. +/// Ein `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard +/// nicht auf. Der Zustand lebt nur im Prozess (Spec #1 § 5.4, R12): Aus dem +/// `amr`-Claim lässt er sich nicht ableiten. +class AuthStatusTracker { + bool _recovery = false; + + /// Status für [session] unter Berücksichtigung der laufenden Recovery. + AuthStatus statusFor(Session? session) { + final user = session?.user; + if (user == null) { + return const AuthStatus.signedOut(); + } + final authUser = AuthUser( + id: user.id, + email: user.email, + emailConfirmed: user.emailConfirmedAt != null, + ); + return _recovery + ? AuthStatus.passwordRecovery(authUser) + : AuthStatus.signedIn(authUser); + } + + /// Verarbeitet ein Ereignis und liefert den neuen Status. + AuthStatus handle(AuthChangeEvent event, Session? session) { + switch (event) { + case AuthChangeEvent.passwordRecovery: + _recovery = session != null; + case AuthChangeEvent.signedOut || AuthChangeEvent.userUpdated: + _recovery = false; + default: + break; + } + if (session == null) { + _recovery = false; + } + return statusFor(session); + } +} diff --git a/app/lib/features/auth/data/supabase_auth_repository.dart b/app/lib/features/auth/data/supabase_auth_repository.dart new file mode 100644 index 0000000..fec916c --- /dev/null +++ b/app/lib/features/auth/data/supabase_auth_repository.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/data/auth_failure_mapper.dart'; +import 'package:shuffle/features/auth/data/auth_status_tracker.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_repository.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// [AuthRepository] gegen Supabase Auth (GoTrue). +/// +/// Logs enthalten weder E-Mail-Adressen noch Passwörter noch Tokens, nur +/// Operation, Fehlerart, HTTP-Status und Fehlercode. +class SupabaseAuthRepository implements AuthRepository { + SupabaseAuthRepository(this._auth) { + _lastStatus = _tracker.statusFor(_auth.currentSession); + _subscription = _auth.onAuthStateChange.listen( + _onAuthState, + onError: (Object error, StackTrace stackTrace) { + _log.warning('auth state stream error: ${error.runtimeType}'); + }, + ); + } + + final GoTrueClient _auth; + final _tracker = AuthStatusTracker(); + final _changes = StreamController.broadcast(sync: true); + final _log = AppLogger('AuthRepository'); + late AuthStatus _lastStatus; + StreamSubscription? _subscription; + + void _onAuthState(AuthState state) { + final next = _tracker.handle(state.event, state.session); + if (next != _lastStatus) { + _lastStatus = next; + _changes.add(next); + } + } + + /// Beendet das Abonnieren der Auth-Ereignisse. + Future dispose() async { + await _subscription?.cancel(); + _subscription = null; + await _changes.close(); + } + + @override + AuthStatus get currentStatus => _lastStatus; + + @override + Stream watchStatus() { + // ignore: close_sinks, wird vom Abonnenten über cancel geschlossen. + late StreamController controller; + StreamSubscription? inner; + controller = StreamController( + onListen: () { + controller.add(_lastStatus); + inner = _changes.stream.listen(controller.add); + }, + onCancel: () => inner?.cancel(), + ); + return controller.stream; + } + + @override + Future> signUp(RegistrationInput input) => _guard( + AuthOperation.signUp, + () => _auth.signUp( + email: input.email.trim(), + password: input.password, + data: { + 'display_name': input.displayName.trim(), + // JSON-Booleans: Der Trigger vergleicht `meta ->> '…'` mit 'true'. + 'age_confirmed': input.ageConfirmed, + 'terms_accepted': input.termsAccepted, + 'terms_version': input.termsVersion, + 'locale': input.locale, + }, + ), + ); + + @override + Future> resendConfirmation(String email) => _guard( + AuthOperation.resend, + () => _auth.resend(type: OtpType.signup, email: email.trim()), + ); + + @override + Future> signIn(String email, String password) => + _guard( + AuthOperation.signIn, + () => _auth.signInWithPassword(email: email.trim(), password: password), + ); + + @override + Future> requestPasswordReset(String email) => + _guard( + AuthOperation.requestPasswordReset, + () => _auth.resetPasswordForEmail(email.trim()), + ); + + @override + Future> verifyEmailLink( + String tokenHash, + EmailLinkType type, + ) => _guard(AuthOperation.verifyLink, () async { + final response = await _auth.verifyOTP( + tokenHash: tokenHash, + type: switch (type) { + EmailLinkType.confirmation => OtpType.email, + EmailLinkType.recovery => OtpType.recovery, + }, + ); + if (response.session == null) { + throw StateError('verifyOTP returned no session'); + } + }); + + @override + Future> setNewPassword(String password) => _guard( + AuthOperation.updatePassword, + () => _auth.updateUser(UserAttributes(password: password)), + ); + + @override + Future signOut() async { + try { + await _auth.signOut(); + } on Object catch (error) { + _log.warning('signOut failed: ${error.runtimeType}'); + } + } + + Future> _guard( + AuthOperation operation, + Future Function() action, + ) async { + try { + await action(); + return const Result.success(null); + } on Object catch (error) { + final failure = mapAuthError(error, operation); + _logFailure(operation, error, failure); + return Result.failure(failure); + } + } + + void _logFailure(AuthOperation operation, Object error, AuthFailure failure) { + final status = error is AuthException ? error.statusCode : null; + final code = error is AuthException ? error.code : null; + final reason = failure is RegistrationRejected + ? ' reason=${failure.reason.name}' + : ''; + _log.warning( + '${operation.name} failed: ${failure.runtimeType} ' + 'error=${error.runtimeType} status=$status code=$code$reason', + ); + } +} diff --git a/app/lib/features/auth/domain/auth_failure.dart b/app/lib/features/auth/domain/auth_failure.dart new file mode 100644 index 0000000..b924f6f --- /dev/null +++ b/app/lib/features/auth/domain/auth_failure.dart @@ -0,0 +1,43 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_failure.freezed.dart'; + +/// Grund einer abgelehnten Passwortstärke (`weak_password.reasons`). +enum WeakPasswordReason { length, characters, pwned, unknown } + +/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung). +/// +/// Nur zum Loggen und für Zusatzhinweise: Das Format der Meldung ist nicht +/// zugesichert (Spec #1 § 4.4), unbekannte Meldungen ergeben [unknown]. +enum RegistrationRejectionReason { + displayName, + ageConfirmation, + termsAcceptance, + termsVersion, + unknown, +} + +/// Fachliche Fehler der Auth-Abläufe (Mapping: Spec #1 § 4.4). +@freezed +sealed class AuthFailure with _$AuthFailure { + const factory AuthFailure.invalidCredentials() = InvalidCredentials; + const factory AuthFailure.emailNotConfirmed() = EmailNotConfirmed; + + /// `over_email_send_rate_limit`. Wo die Adresse frei eingegeben wird, wird + /// das wie Erfolg behandelt (F12), siehe `resendFailureFor`. + const factory AuthFailure.emailSendLimited() = EmailSendLimited; + + /// `over_request_rate_limit` und sonstiges HTTP 429. + const factory AuthFailure.rateLimited() = RateLimited; + const factory AuthFailure.weakPassword(List reasons) = + WeakPassword; + const factory AuthFailure.samePassword() = SamePassword; + const factory AuthFailure.linkInvalidOrExpired() = LinkInvalidOrExpired; + const factory AuthFailure.invalidEmail() = InvalidEmail; + const factory AuthFailure.registrationRejected({ + @Default(RegistrationRejectionReason.unknown) + RegistrationRejectionReason reason, + }) = RegistrationRejected; + const factory AuthFailure.network() = NetworkFailure; + const factory AuthFailure.unexpected() = Unexpected; +} diff --git a/app/lib/features/auth/domain/auth_failure.freezed.dart b/app/lib/features/auth/domain/auth_failure.freezed.dart new file mode 100644 index 0000000..9dae83a --- /dev/null +++ b/app/lib/features/auth/domain/auth_failure.freezed.dart @@ -0,0 +1,659 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_failure.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthFailure { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure()'; +} + + +} + +/// @nodoc +class $AuthFailureCopyWith<$Res> { +$AuthFailureCopyWith(AuthFailure _, $Res Function(AuthFailure) __); +} + + +/// Adds pattern-matching-related methods to [AuthFailure]. +extension AuthFailurePatterns on AuthFailure { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( InvalidCredentials value)? invalidCredentials,TResult Function( EmailNotConfirmed value)? emailNotConfirmed,TResult Function( EmailSendLimited value)? emailSendLimited,TResult Function( RateLimited value)? rateLimited,TResult Function( WeakPassword value)? weakPassword,TResult Function( SamePassword value)? samePassword,TResult Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult Function( InvalidEmail value)? invalidEmail,TResult Function( RegistrationRejected value)? registrationRejected,TResult Function( NetworkFailure value)? network,TResult Function( Unexpected value)? unexpected,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case InvalidCredentials() when invalidCredentials != null: +return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null: +return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null: +return emailSendLimited(_that);case RateLimited() when rateLimited != null: +return rateLimited(_that);case WeakPassword() when weakPassword != null: +return weakPassword(_that);case SamePassword() when samePassword != null: +return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null: +return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null: +return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null: +return registrationRejected(_that);case NetworkFailure() when network != null: +return network(_that);case Unexpected() when unexpected != null: +return unexpected(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( InvalidCredentials value) invalidCredentials,required TResult Function( EmailNotConfirmed value) emailNotConfirmed,required TResult Function( EmailSendLimited value) emailSendLimited,required TResult Function( RateLimited value) rateLimited,required TResult Function( WeakPassword value) weakPassword,required TResult Function( SamePassword value) samePassword,required TResult Function( LinkInvalidOrExpired value) linkInvalidOrExpired,required TResult Function( InvalidEmail value) invalidEmail,required TResult Function( RegistrationRejected value) registrationRejected,required TResult Function( NetworkFailure value) network,required TResult Function( Unexpected value) unexpected,}){ +final _that = this; +switch (_that) { +case InvalidCredentials(): +return invalidCredentials(_that);case EmailNotConfirmed(): +return emailNotConfirmed(_that);case EmailSendLimited(): +return emailSendLimited(_that);case RateLimited(): +return rateLimited(_that);case WeakPassword(): +return weakPassword(_that);case SamePassword(): +return samePassword(_that);case LinkInvalidOrExpired(): +return linkInvalidOrExpired(_that);case InvalidEmail(): +return invalidEmail(_that);case RegistrationRejected(): +return registrationRejected(_that);case NetworkFailure(): +return network(_that);case Unexpected(): +return unexpected(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( InvalidCredentials value)? invalidCredentials,TResult? Function( EmailNotConfirmed value)? emailNotConfirmed,TResult? Function( EmailSendLimited value)? emailSendLimited,TResult? Function( RateLimited value)? rateLimited,TResult? Function( WeakPassword value)? weakPassword,TResult? Function( SamePassword value)? samePassword,TResult? Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult? Function( InvalidEmail value)? invalidEmail,TResult? Function( RegistrationRejected value)? registrationRejected,TResult? Function( NetworkFailure value)? network,TResult? Function( Unexpected value)? unexpected,}){ +final _that = this; +switch (_that) { +case InvalidCredentials() when invalidCredentials != null: +return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null: +return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null: +return emailSendLimited(_that);case RateLimited() when rateLimited != null: +return rateLimited(_that);case WeakPassword() when weakPassword != null: +return weakPassword(_that);case SamePassword() when samePassword != null: +return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null: +return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null: +return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null: +return registrationRejected(_that);case NetworkFailure() when network != null: +return network(_that);case Unexpected() when unexpected != null: +return unexpected(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? invalidCredentials,TResult Function()? emailNotConfirmed,TResult Function()? emailSendLimited,TResult Function()? rateLimited,TResult Function( List reasons)? weakPassword,TResult Function()? samePassword,TResult Function()? linkInvalidOrExpired,TResult Function()? invalidEmail,TResult Function( RegistrationRejectionReason reason)? registrationRejected,TResult Function()? network,TResult Function()? unexpected,required TResult orElse(),}) {final _that = this; +switch (_that) { +case InvalidCredentials() when invalidCredentials != null: +return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null: +return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null: +return emailSendLimited();case RateLimited() when rateLimited != null: +return rateLimited();case WeakPassword() when weakPassword != null: +return weakPassword(_that.reasons);case SamePassword() when samePassword != null: +return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null: +return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null: +return invalidEmail();case RegistrationRejected() when registrationRejected != null: +return registrationRejected(_that.reason);case NetworkFailure() when network != null: +return network();case Unexpected() when unexpected != null: +return unexpected();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() invalidCredentials,required TResult Function() emailNotConfirmed,required TResult Function() emailSendLimited,required TResult Function() rateLimited,required TResult Function( List reasons) weakPassword,required TResult Function() samePassword,required TResult Function() linkInvalidOrExpired,required TResult Function() invalidEmail,required TResult Function( RegistrationRejectionReason reason) registrationRejected,required TResult Function() network,required TResult Function() unexpected,}) {final _that = this; +switch (_that) { +case InvalidCredentials(): +return invalidCredentials();case EmailNotConfirmed(): +return emailNotConfirmed();case EmailSendLimited(): +return emailSendLimited();case RateLimited(): +return rateLimited();case WeakPassword(): +return weakPassword(_that.reasons);case SamePassword(): +return samePassword();case LinkInvalidOrExpired(): +return linkInvalidOrExpired();case InvalidEmail(): +return invalidEmail();case RegistrationRejected(): +return registrationRejected(_that.reason);case NetworkFailure(): +return network();case Unexpected(): +return unexpected();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? invalidCredentials,TResult? Function()? emailNotConfirmed,TResult? Function()? emailSendLimited,TResult? Function()? rateLimited,TResult? Function( List reasons)? weakPassword,TResult? Function()? samePassword,TResult? Function()? linkInvalidOrExpired,TResult? Function()? invalidEmail,TResult? Function( RegistrationRejectionReason reason)? registrationRejected,TResult? Function()? network,TResult? Function()? unexpected,}) {final _that = this; +switch (_that) { +case InvalidCredentials() when invalidCredentials != null: +return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null: +return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null: +return emailSendLimited();case RateLimited() when rateLimited != null: +return rateLimited();case WeakPassword() when weakPassword != null: +return weakPassword(_that.reasons);case SamePassword() when samePassword != null: +return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null: +return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null: +return invalidEmail();case RegistrationRejected() when registrationRejected != null: +return registrationRejected(_that.reason);case NetworkFailure() when network != null: +return network();case Unexpected() when unexpected != null: +return unexpected();case _: + return null; + +} +} + +} + +/// @nodoc + + +class InvalidCredentials implements AuthFailure { + const InvalidCredentials(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidCredentials); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.invalidCredentials()'; +} + + +} + + + + +/// @nodoc + + +class EmailNotConfirmed implements AuthFailure { + const EmailNotConfirmed(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailNotConfirmed); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.emailNotConfirmed()'; +} + + +} + + + + +/// @nodoc + + +class EmailSendLimited implements AuthFailure { + const EmailSendLimited(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailSendLimited); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.emailSendLimited()'; +} + + +} + + + + +/// @nodoc + + +class RateLimited implements AuthFailure { + const RateLimited(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RateLimited); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.rateLimited()'; +} + + +} + + + + +/// @nodoc + + +class WeakPassword implements AuthFailure { + const WeakPassword( List reasons): _reasons = reasons; + + + final List _reasons; + List get reasons { + if (_reasons is EqualUnmodifiableListView) return _reasons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_reasons); +} + + +/// Create a copy of AuthFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WeakPasswordCopyWith get copyWith => _$WeakPasswordCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WeakPassword&&const DeepCollectionEquality().equals(other.reasons, _reasons)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,const DeepCollectionEquality().hash(_reasons)); +} + +@override +String toString() { + return 'AuthFailure.weakPassword(reasons: $reasons)'; +} + + +} + +/// @nodoc +abstract mixin class $WeakPasswordCopyWith<$Res> implements $AuthFailureCopyWith<$Res> { + factory $WeakPasswordCopyWith(WeakPassword value, $Res Function(WeakPassword) _then) = _$WeakPasswordCopyWithImpl; +@useResult +$Res call({ + List reasons +}); + + + + +} +/// @nodoc +class _$WeakPasswordCopyWithImpl<$Res> + implements $WeakPasswordCopyWith<$Res> { + _$WeakPasswordCopyWithImpl(this._self, this._then); + + final WeakPassword _self; + final $Res Function(WeakPassword) _then; + +/// Create a copy of AuthFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? reasons = null,}) { + return _then(WeakPassword( +null == reasons ? _self._reasons : reasons // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class SamePassword implements AuthFailure { + const SamePassword(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SamePassword); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.samePassword()'; +} + + +} + + + + +/// @nodoc + + +class LinkInvalidOrExpired implements AuthFailure { + const LinkInvalidOrExpired(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LinkInvalidOrExpired); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.linkInvalidOrExpired()'; +} + + +} + + + + +/// @nodoc + + +class InvalidEmail implements AuthFailure { + const InvalidEmail(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidEmail); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.invalidEmail()'; +} + + +} + + + + +/// @nodoc + + +class RegistrationRejected implements AuthFailure { + const RegistrationRejected({this.reason = RegistrationRejectionReason.unknown}); + + +@JsonKey() final RegistrationRejectionReason reason; + +/// Create a copy of AuthFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RegistrationRejectedCopyWith get copyWith => _$RegistrationRejectedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationRejected&&(identical(other.reason, reason) || other.reason == reason)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,reason); +} + +@override +String toString() { + return 'AuthFailure.registrationRejected(reason: $reason)'; +} + + +} + +/// @nodoc +abstract mixin class $RegistrationRejectedCopyWith<$Res> implements $AuthFailureCopyWith<$Res> { + factory $RegistrationRejectedCopyWith(RegistrationRejected value, $Res Function(RegistrationRejected) _then) = _$RegistrationRejectedCopyWithImpl; +@useResult +$Res call({ + RegistrationRejectionReason reason +}); + + + + +} +/// @nodoc +class _$RegistrationRejectedCopyWithImpl<$Res> + implements $RegistrationRejectedCopyWith<$Res> { + _$RegistrationRejectedCopyWithImpl(this._self, this._then); + + final RegistrationRejected _self; + final $Res Function(RegistrationRejected) _then; + +/// Create a copy of AuthFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) { + return _then(RegistrationRejected( +reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as RegistrationRejectionReason, + )); +} + + +} + +/// @nodoc + + +class NetworkFailure implements AuthFailure { + const NetworkFailure(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NetworkFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.network()'; +} + + +} + + + + +/// @nodoc + + +class Unexpected implements AuthFailure { + const Unexpected(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Unexpected); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthFailure.unexpected()'; +} + + +} + + + + +// dart format on diff --git a/app/lib/features/auth/domain/auth_repository.dart b/app/lib/features/auth/domain/auth_repository.dart new file mode 100644 index 0000000..0737d5b --- /dev/null +++ b/app/lib/features/auth/domain/auth_repository.dart @@ -0,0 +1,35 @@ +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; + +/// Zugriff auf die Anmeldung (Implementierung: Supabase/GoTrue). +abstract interface class AuthRepository { + /// Aktueller Status gefolgt von allen Änderungen. + Stream watchStatus(); + + /// Aktueller Status, synchron (für Router-Redirects). + AuthStatus get currentStatus; + + /// Registriert ein Konto. Jede 200-Antwort ist Erfolg; ob die Adresse schon + /// existiert, wird nicht ausgewertet (KTO-01 Kriterium 4). + Future> signUp(RegistrationInput input); + + Future> resendConfirmation(String email); + + Future> signIn(String email, String password); + + Future> requestPasswordReset(String email); + + Future> verifyEmailLink( + String tokenHash, + EmailLinkType type, + ); + + Future> setNewPassword(String password); + + /// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur + /// geloggt. + Future signOut(); +} diff --git a/app/lib/features/auth/domain/auth_status.dart b/app/lib/features/auth/domain/auth_status.dart new file mode 100644 index 0000000..6f6588b --- /dev/null +++ b/app/lib/features/auth/domain/auth_status.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; + +part 'auth_status.freezed.dart'; + +/// Anmeldestatus der App. +/// +/// [AuthStatus.passwordRecovery] gilt nur im laufenden Prozess: Eine +/// Recovery-Sitzung ist nach einem Neustart nicht mehr von einer normalen +/// Anmeldung zu unterscheiden (Spec #1 § 5.4, Risiko R12). +@freezed +sealed class AuthStatus with _$AuthStatus { + const AuthStatus._(); + + const factory AuthStatus.signedOut() = SignedOut; + const factory AuthStatus.signedIn(AuthUser user) = SignedIn; + const factory AuthStatus.passwordRecovery(AuthUser user) = PasswordRecovery; + + bool get isSignedOut => this is SignedOut; + bool get isPasswordRecovery => this is PasswordRecovery; +} diff --git a/app/lib/features/auth/domain/auth_status.freezed.dart b/app/lib/features/auth/domain/auth_status.freezed.dart new file mode 100644 index 0000000..0bf1664 --- /dev/null +++ b/app/lib/features/auth/domain/auth_status.freezed.dart @@ -0,0 +1,367 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_status.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthStatus { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthStatus); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthStatus()'; +} + + +} + +/// @nodoc +class $AuthStatusCopyWith<$Res> { +$AuthStatusCopyWith(AuthStatus _, $Res Function(AuthStatus) __); +} + + +/// Adds pattern-matching-related methods to [AuthStatus]. +extension AuthStatusPatterns on AuthStatus { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( SignedOut value)? signedOut,TResult Function( SignedIn value)? signedIn,TResult Function( PasswordRecovery value)? passwordRecovery,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case SignedOut() when signedOut != null: +return signedOut(_that);case SignedIn() when signedIn != null: +return signedIn(_that);case PasswordRecovery() when passwordRecovery != null: +return passwordRecovery(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( SignedOut value) signedOut,required TResult Function( SignedIn value) signedIn,required TResult Function( PasswordRecovery value) passwordRecovery,}){ +final _that = this; +switch (_that) { +case SignedOut(): +return signedOut(_that);case SignedIn(): +return signedIn(_that);case PasswordRecovery(): +return passwordRecovery(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( SignedOut value)? signedOut,TResult? Function( SignedIn value)? signedIn,TResult? Function( PasswordRecovery value)? passwordRecovery,}){ +final _that = this; +switch (_that) { +case SignedOut() when signedOut != null: +return signedOut(_that);case SignedIn() when signedIn != null: +return signedIn(_that);case PasswordRecovery() when passwordRecovery != null: +return passwordRecovery(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user)? passwordRecovery,required TResult orElse(),}) {final _that = this; +switch (_that) { +case SignedOut() when signedOut != null: +return signedOut();case SignedIn() when signedIn != null: +return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null: +return passwordRecovery(_that.user);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user) passwordRecovery,}) {final _that = this; +switch (_that) { +case SignedOut(): +return signedOut();case SignedIn(): +return signedIn(_that.user);case PasswordRecovery(): +return passwordRecovery(_that.user);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user)? passwordRecovery,}) {final _that = this; +switch (_that) { +case SignedOut() when signedOut != null: +return signedOut();case SignedIn() when signedIn != null: +return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null: +return passwordRecovery(_that.user);case _: + return null; + +} +} + +} + +/// @nodoc + + +class SignedOut extends AuthStatus { + const SignedOut(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedOut); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthStatus.signedOut()'; +} + + +} + + + + +/// @nodoc + + +class SignedIn extends AuthStatus { + const SignedIn(this.user): super._(); + + + final AuthUser user; + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SignedInCopyWith get copyWith => _$SignedInCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedIn&&(identical(other.user, user) || other.user == user)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,user); +} + +@override +String toString() { + return 'AuthStatus.signedIn(user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class $SignedInCopyWith<$Res> implements $AuthStatusCopyWith<$Res> { + factory $SignedInCopyWith(SignedIn value, $Res Function(SignedIn) _then) = _$SignedInCopyWithImpl; +@useResult +$Res call({ + AuthUser user +}); + + +$AuthUserCopyWith<$Res> get user; + +} +/// @nodoc +class _$SignedInCopyWithImpl<$Res> + implements $SignedInCopyWith<$Res> { + _$SignedInCopyWithImpl(this._self, this._then); + + final SignedIn _self; + final $Res Function(SignedIn) _then; + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? user = null,}) { + return _then(SignedIn( +null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as AuthUser, + )); +} + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthUserCopyWith<$Res> get user { + + return $AuthUserCopyWith<$Res>(_self.user, (value) { + return _then(_self.copyWith(user: value)); + }); +} +} + +/// @nodoc + + +class PasswordRecovery extends AuthStatus { + const PasswordRecovery(this.user): super._(); + + + final AuthUser user; + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PasswordRecoveryCopyWith get copyWith => _$PasswordRecoveryCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordRecovery&&(identical(other.user, user) || other.user == user)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,user); +} + +@override +String toString() { + return 'AuthStatus.passwordRecovery(user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class $PasswordRecoveryCopyWith<$Res> implements $AuthStatusCopyWith<$Res> { + factory $PasswordRecoveryCopyWith(PasswordRecovery value, $Res Function(PasswordRecovery) _then) = _$PasswordRecoveryCopyWithImpl; +@useResult +$Res call({ + AuthUser user +}); + + +$AuthUserCopyWith<$Res> get user; + +} +/// @nodoc +class _$PasswordRecoveryCopyWithImpl<$Res> + implements $PasswordRecoveryCopyWith<$Res> { + _$PasswordRecoveryCopyWithImpl(this._self, this._then); + + final PasswordRecovery _self; + final $Res Function(PasswordRecovery) _then; + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? user = null,}) { + return _then(PasswordRecovery( +null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as AuthUser, + )); +} + +/// Create a copy of AuthStatus +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthUserCopyWith<$Res> get user { + + return $AuthUserCopyWith<$Res>(_self.user, (value) { + return _then(_self.copyWith(user: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/domain/auth_user.dart b/app/lib/features/auth/domain/auth_user.dart new file mode 100644 index 0000000..8081f4f --- /dev/null +++ b/app/lib/features/auth/domain/auth_user.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_user.freezed.dart'; + +/// Angemeldeter Nutzer, soweit die UI ihn braucht. +@freezed +abstract class AuthUser with _$AuthUser { + const factory AuthUser({ + required String id, + required String? email, + required bool emailConfirmed, + }) = _AuthUser; +} diff --git a/app/lib/features/auth/domain/auth_user.freezed.dart b/app/lib/features/auth/domain/auth_user.freezed.dart new file mode 100644 index 0000000..3c88fde --- /dev/null +++ b/app/lib/features/auth/domain/auth_user.freezed.dart @@ -0,0 +1,285 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_user.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthUser { + + String get id; String? get email; bool get emailConfirmed; +/// Create a copy of AuthUser +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthUserCopyWith get copyWith => _$AuthUserCopyWithImpl(this as AuthUser, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as AuthUser; + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthUser&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.emailConfirmed, _this.emailConfirmed) || other.emailConfirmed == _this.emailConfirmed)); +} + + +@override +int get hashCode { + final _this = this as AuthUser; + return Object.hash(runtimeType,_this.id,_this.email,_this.emailConfirmed); +} + +@override +String toString() { + final _this = this as AuthUser; + return 'AuthUser(id: ${_this.id}, email: ${_this.email}, emailConfirmed: ${_this.emailConfirmed})'; +} + + +} + +/// @nodoc +abstract mixin class $AuthUserCopyWith<$Res> { + factory $AuthUserCopyWith(AuthUser value, $Res Function(AuthUser) _then) = _$AuthUserCopyWithImpl; +@useResult +$Res call({ + String id, String? email, bool emailConfirmed +}); + + + + +} +/// @nodoc +class _$AuthUserCopyWithImpl<$Res> + implements $AuthUserCopyWith<$Res> { + _$AuthUserCopyWithImpl(this._self, this._then); + + final AuthUser _self; + final $Res Function(AuthUser) _then; + +/// Create a copy of AuthUser +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) { + return _then(AuthUser( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AuthUser]. +extension AuthUserPatterns on AuthUser { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AuthUser value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AuthUser() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AuthUser value) $default,){ +final _that = this; +switch (_that) { +case _AuthUser(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AuthUser value)? $default,){ +final _that = this; +switch (_that) { +case _AuthUser() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String? email, bool emailConfirmed)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AuthUser() when $default != null: +return $default(_that.id,_that.email,_that.emailConfirmed);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String? email, bool emailConfirmed) $default,) {final _that = this; +switch (_that) { +case _AuthUser(): +return $default(_that.id,_that.email,_that.emailConfirmed);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String? email, bool emailConfirmed)? $default,) {final _that = this; +switch (_that) { +case _AuthUser() when $default != null: +return $default(_that.id,_that.email,_that.emailConfirmed);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AuthUser implements AuthUser { + const _AuthUser({required this.id, required this.email, required this.emailConfirmed}); + + +@override final String id; +@override final String? email; +@override final bool emailConfirmed; + +/// Create a copy of AuthUser +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthUserCopyWith<_AuthUser> get copyWith => __$AuthUserCopyWithImpl<_AuthUser>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthUser&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.emailConfirmed, emailConfirmed) || other.emailConfirmed == emailConfirmed)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,id,email,emailConfirmed); +} + +@override +String toString() { + return 'AuthUser(id: $id, email: $email, emailConfirmed: $emailConfirmed)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthUserCopyWith<$Res> implements $AuthUserCopyWith<$Res> { + factory _$AuthUserCopyWith(_AuthUser value, $Res Function(_AuthUser) _then) = __$AuthUserCopyWithImpl; +@override @useResult +$Res call({ + String id, String? email, bool emailConfirmed +}); + + + + +} +/// @nodoc +class __$AuthUserCopyWithImpl<$Res> + implements _$AuthUserCopyWith<$Res> { + __$AuthUserCopyWithImpl(this._self, this._then); + + final _AuthUser _self; + final $Res Function(_AuthUser) _then; + +/// Create a copy of AuthUser +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) { + return _then(_AuthUser( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/app/lib/features/auth/domain/email_link_request.dart b/app/lib/features/auth/domain/email_link_request.dart new file mode 100644 index 0000000..e2e98a4 --- /dev/null +++ b/app/lib/features/auth/domain/email_link_request.dart @@ -0,0 +1,19 @@ +import 'package:shuffle/features/auth/domain/email_link_type.dart'; + +/// Parameters of a mail link `/auth/confirm?token_hash=…&type=…`. +class EmailLinkRequest { + const EmailLinkRequest({required this.tokenHash, required this.type}); + + final String tokenHash; + final EmailLinkType type; + + /// `null` if the token is missing or the type is unknown. + static EmailLinkRequest? tryParse(String? tokenHash, String? type) { + final parsedType = EmailLinkType.tryParse(type); + final token = tokenHash?.trim(); + if (token == null || token.isEmpty || parsedType == null) { + return null; + } + return EmailLinkRequest(tokenHash: token, type: parsedType); + } +} diff --git a/app/lib/features/auth/domain/email_link_type.dart b/app/lib/features/auth/domain/email_link_type.dart new file mode 100644 index 0000000..01bf8ff --- /dev/null +++ b/app/lib/features/auth/domain/email_link_type.dart @@ -0,0 +1,13 @@ +/// Art des Mail-Links (`type` in `/auth/confirm?token_hash=…&type=…`). +enum EmailLinkType { + confirmation, + recovery; + + /// Liest den `type`-Parameter. `email` und `signup` bedeuten Bestätigung + /// (beide akzeptiert, Spike B0 (b)). Alles andere: `null`. + static EmailLinkType? tryParse(String? value) => switch (value) { + 'email' || 'signup' => EmailLinkType.confirmation, + 'recovery' => EmailLinkType.recovery, + _ => null, + }; +} diff --git a/app/lib/features/auth/domain/registration_input.dart b/app/lib/features/auth/domain/registration_input.dart new file mode 100644 index 0000000..36ebfc8 --- /dev/null +++ b/app/lib/features/auth/domain/registration_input.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'registration_input.freezed.dart'; + +/// Eingaben der Registrierung (Spec #1 § 5.2). +@freezed +abstract class RegistrationInput with _$RegistrationInput { + const factory RegistrationInput({ + required String email, + required String password, + required String displayName, + required bool ageConfirmed, + required bool termsAccepted, + required String termsVersion, + + /// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache. + required String locale, + }) = _RegistrationInput; +} diff --git a/app/lib/features/auth/domain/registration_input.freezed.dart b/app/lib/features/auth/domain/registration_input.freezed.dart new file mode 100644 index 0000000..56116bb --- /dev/null +++ b/app/lib/features/auth/domain/registration_input.freezed.dart @@ -0,0 +1,299 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'registration_input.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RegistrationInput { + + String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; String get termsVersion;/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache. + String get locale; +/// Create a copy of RegistrationInput +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RegistrationInputCopyWith get copyWith => _$RegistrationInputCopyWithImpl(this as RegistrationInput, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as RegistrationInput; + return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationInput&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.displayName, _this.displayName) || other.displayName == _this.displayName)&&(identical(other.ageConfirmed, _this.ageConfirmed) || other.ageConfirmed == _this.ageConfirmed)&&(identical(other.termsAccepted, _this.termsAccepted) || other.termsAccepted == _this.termsAccepted)&&(identical(other.termsVersion, _this.termsVersion) || other.termsVersion == _this.termsVersion)&&(identical(other.locale, _this.locale) || other.locale == _this.locale)); +} + + +@override +int get hashCode { + final _this = this as RegistrationInput; + return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,_this.termsVersion,_this.locale); +} + +@override +String toString() { + final _this = this as RegistrationInput; + return 'RegistrationInput(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, termsVersion: ${_this.termsVersion}, locale: ${_this.locale})'; +} + + +} + +/// @nodoc +abstract mixin class $RegistrationInputCopyWith<$Res> { + factory $RegistrationInputCopyWith(RegistrationInput value, $Res Function(RegistrationInput) _then) = _$RegistrationInputCopyWithImpl; +@useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale +}); + + + + +} +/// @nodoc +class _$RegistrationInputCopyWithImpl<$Res> + implements $RegistrationInputCopyWith<$Res> { + _$RegistrationInputCopyWithImpl(this._self, this._then); + + final RegistrationInput _self; + final $Res Function(RegistrationInput) _then; + +/// Create a copy of RegistrationInput +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? termsVersion = null,Object? locale = null,}) { + return _then(RegistrationInput( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable +as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RegistrationInput]. +extension RegistrationInputPatterns on RegistrationInput { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RegistrationInput value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RegistrationInput() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RegistrationInput value) $default,){ +final _that = this; +switch (_that) { +case _RegistrationInput(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RegistrationInput value)? $default,){ +final _that = this; +switch (_that) { +case _RegistrationInput() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RegistrationInput() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale) $default,) {final _that = this; +switch (_that) { +case _RegistrationInput(): +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale)? $default,) {final _that = this; +switch (_that) { +case _RegistrationInput() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RegistrationInput implements RegistrationInput { + const _RegistrationInput({required this.email, required this.password, required this.displayName, required this.ageConfirmed, required this.termsAccepted, required this.termsVersion, required this.locale}); + + +@override final String email; +@override final String password; +@override final String displayName; +@override final bool ageConfirmed; +@override final bool termsAccepted; +@override final String termsVersion; +/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache. +@override final String locale; + +/// Create a copy of RegistrationInput +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RegistrationInputCopyWith<_RegistrationInput> get copyWith => __$RegistrationInputCopyWithImpl<_RegistrationInput>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegistrationInput&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.ageConfirmed, ageConfirmed) || other.ageConfirmed == ageConfirmed)&&(identical(other.termsAccepted, termsAccepted) || other.termsAccepted == termsAccepted)&&(identical(other.termsVersion, termsVersion) || other.termsVersion == termsVersion)&&(identical(other.locale, locale) || other.locale == locale)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,termsVersion,locale); +} + +@override +String toString() { + return 'RegistrationInput(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, termsVersion: $termsVersion, locale: $locale)'; +} + + +} + +/// @nodoc +abstract mixin class _$RegistrationInputCopyWith<$Res> implements $RegistrationInputCopyWith<$Res> { + factory _$RegistrationInputCopyWith(_RegistrationInput value, $Res Function(_RegistrationInput) _then) = __$RegistrationInputCopyWithImpl; +@override @useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale +}); + + + + +} +/// @nodoc +class __$RegistrationInputCopyWithImpl<$Res> + implements _$RegistrationInputCopyWith<$Res> { + __$RegistrationInputCopyWithImpl(this._self, this._then); + + final _RegistrationInput _self; + final $Res Function(_RegistrationInput) _then; + +/// Create a copy of RegistrationInput +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? termsVersion = null,Object? locale = null,}) { + return _then(_RegistrationInput( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable +as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/app/lib/features/auth/domain/resend_origin.dart b/app/lib/features/auth/domain/resend_origin.dart new file mode 100644 index 0000000..6a29c2a --- /dev/null +++ b/app/lib/features/auth/domain/resend_origin.dart @@ -0,0 +1,30 @@ +import 'package:shuffle/features/auth/domain/auth_failure.dart'; + +/// Von wo „E-Mail erneut senden“ ausgelöst wird (Spec #1 § 4.4, F12). +enum ResendOrigin { + /// CheckEmailScreen: Die Adresse kann frei eingegeben sein. + checkEmail, + + /// LoginScreen nach `email_not_confirmed`: Das Passwort war richtig. + login, +} + +/// Wendet die Regel aus F12 für „E-Mail erneut senden“ an. +/// +/// `null` heißt: wie Erfolg behandeln. Aus dem LoginScreen wird +/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]). +AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) { + if (failure is EmailSendLimited) { + return switch (origin) { + ResendOrigin.checkEmail => null, + ResendOrigin.login => const AuthFailure.rateLimited(), + }; + } + return failure; +} + +/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited` +/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie +/// Erfolg“, sonst den Fehler. +AuthFailure? openEmailInputFailureFor(AuthFailure failure) => + failure is EmailSendLimited ? null : failure; diff --git a/app/lib/features/auth/domain/validation/common_passwords.dart b/app/lib/features/auth/domain/validation/common_passwords.dart new file mode 100644 index 0000000..ce39de8 --- /dev/null +++ b/app/lib/features/auth/domain/validation/common_passwords.dart @@ -0,0 +1,20 @@ +/// Liste häufiger Passwörter (Implementierung in `data/`). +abstract interface class CommonPasswordList { + /// `true`, wenn [password] auf der Liste steht. Vergleich ohne Groß- und + /// Kleinschreibung, nach Trim. + bool contains(String password); +} + +/// Einfache In-Memory-Liste aus bereits normalisierten Einträgen +/// (Kleinschreibung, ohne Leerraum am Rand). +class SetCommonPasswordList implements CommonPasswordList { + const SetCommonPasswordList(this._entries); + + final Set _entries; + + int get length => _entries.length; + + @override + bool contains(String password) => + _entries.contains(password.trim().toLowerCase()); +} diff --git a/app/lib/features/auth/domain/validation/display_name_rule.dart b/app/lib/features/auth/domain/validation/display_name_rule.dart new file mode 100644 index 0000000..e64f510 --- /dev/null +++ b/app/lib/features/auth/domain/validation/display_name_rule.dart @@ -0,0 +1,42 @@ +/// Fehler des Anzeigenamens. +enum DisplayNameError { + empty, + tooLong, + + /// Enthält U+200B (Zero Width Space). Die UI zeigt dieselbe Meldung wie für + /// einen ungültigen Namen (Spec #1 § 5.4), kein eigener Text. + invalidCharacter, +} + +/// Regel für Anzeigenamen (KTO-01 Kriterium 10, KTO-02 Kriterium 1). +/// +/// Identisch zum Server (`public.trim_ws` + `char_length`): Leerraum am Rand +/// wird entfernt (Dart `trim()` und `trim_ws` entfernen dieselben Zeichen, +/// auch NBSP und Tab). Danach 1 bis 30 Unicode-Codepoints, gezählt über +/// `runes`, nicht über `String.length` (UTF-16). U+200B wird nicht getrimmt, +/// sondern überall abgelehnt. +abstract final class DisplayNameRule { + static const maxLength = 30; + static const zeroWidthSpace = '​'; + + /// Der Wert, der gespeichert und gesendet wird. + static String normalize(String value) => value.trim(); + + /// Länge in Codepoints nach dem Trim (für den Zähler `n/30`). + static int length(String value) => normalize(value).runes.length; + + static DisplayNameError? validate(String value) { + final name = normalize(value); + if (name.contains(zeroWidthSpace)) { + return DisplayNameError.invalidCharacter; + } + final count = name.runes.length; + if (count == 0) { + return DisplayNameError.empty; + } + if (count > maxLength) { + return DisplayNameError.tooLong; + } + return null; + } +} diff --git a/app/lib/features/auth/domain/validation/email_format.dart b/app/lib/features/auth/domain/validation/email_format.dart new file mode 100644 index 0000000..804a137 --- /dev/null +++ b/app/lib/features/auth/domain/validation/email_format.dart @@ -0,0 +1,23 @@ +/// Fehler der E-Mail-Adresse. +enum EmailError { empty, invalid } + +/// Einfache Syntaxprüfung (Feinprüfung macht der Server): ein `@`, Text davor, +/// ein Punkt im Domänenteil, kein Leerraum. +abstract final class EmailFormat { + static const maxLength = 254; + + static final _pattern = RegExp(r'^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$'); + + static String normalize(String value) => value.trim(); + + static EmailError? validate(String value) { + final email = normalize(value); + if (email.isEmpty) { + return EmailError.empty; + } + if (email.length > maxLength || !_pattern.hasMatch(email)) { + return EmailError.invalid; + } + return null; + } +} diff --git a/app/lib/features/auth/domain/validation/password_policy.dart b/app/lib/features/auth/domain/validation/password_policy.dart new file mode 100644 index 0000000..1f9d74f --- /dev/null +++ b/app/lib/features/auth/domain/validation/password_policy.dart @@ -0,0 +1,38 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; + +part 'password_policy.freezed.dart'; + +/// Fehler der Passwortregel. +@freezed +sealed class PasswordError with _$PasswordError { + /// Zu kurz; [missing] Zeichen fehlen noch. + const factory PasswordError.tooShort(int missing) = PasswordTooShort; + const factory PasswordError.common() = PasswordCommon; +} + +/// Passwortregel (KTO-01 Kriterium 3): mindestens 10 Zeichen und nicht auf der +/// Liste häufiger Passwörter. +/// +/// Die Länge zählt Unicode-Codepoints des unveränderten Passworts (Leerzeichen +/// sind gültige Zeichen und werden nicht entfernt). Nur der Listenvergleich +/// trimmt und ignoriert die Groß-/Kleinschreibung. +abstract final class PasswordPolicy { + static const minLength = 10; + + /// Prüft [password]. Ist die Liste noch nicht geladen ([common] `null`), + /// läuft nur die Längenprüfung; vor dem Absenden mit Liste erneut prüfen. + static PasswordError? validate( + String password, { + CommonPasswordList? common, + }) { + final length = password.runes.length; + if (length < minLength) { + return PasswordError.tooShort(minLength - length); + } + if (common != null && common.contains(password)) { + return const PasswordError.common(); + } + return null; + } +} diff --git a/app/lib/features/auth/domain/validation/password_policy.freezed.dart b/app/lib/features/auth/domain/validation/password_policy.freezed.dart new file mode 100644 index 0000000..156411d --- /dev/null +++ b/app/lib/features/auth/domain/validation/password_policy.freezed.dart @@ -0,0 +1,275 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'password_policy.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PasswordError { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordError); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'PasswordError()'; +} + + +} + +/// @nodoc +class $PasswordErrorCopyWith<$Res> { +$PasswordErrorCopyWith(PasswordError _, $Res Function(PasswordError) __); +} + + +/// Adds pattern-matching-related methods to [PasswordError]. +extension PasswordErrorPatterns on PasswordError { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( PasswordTooShort value)? tooShort,TResult Function( PasswordCommon value)? common,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case PasswordTooShort() when tooShort != null: +return tooShort(_that);case PasswordCommon() when common != null: +return common(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( PasswordTooShort value) tooShort,required TResult Function( PasswordCommon value) common,}){ +final _that = this; +switch (_that) { +case PasswordTooShort(): +return tooShort(_that);case PasswordCommon(): +return common(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( PasswordTooShort value)? tooShort,TResult? Function( PasswordCommon value)? common,}){ +final _that = this; +switch (_that) { +case PasswordTooShort() when tooShort != null: +return tooShort(_that);case PasswordCommon() when common != null: +return common(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( int missing)? tooShort,TResult Function()? common,required TResult orElse(),}) {final _that = this; +switch (_that) { +case PasswordTooShort() when tooShort != null: +return tooShort(_that.missing);case PasswordCommon() when common != null: +return common();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( int missing) tooShort,required TResult Function() common,}) {final _that = this; +switch (_that) { +case PasswordTooShort(): +return tooShort(_that.missing);case PasswordCommon(): +return common();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( int missing)? tooShort,TResult? Function()? common,}) {final _that = this; +switch (_that) { +case PasswordTooShort() when tooShort != null: +return tooShort(_that.missing);case PasswordCommon() when common != null: +return common();case _: + return null; + +} +} + +} + +/// @nodoc + + +class PasswordTooShort implements PasswordError { + const PasswordTooShort(this.missing); + + + final int missing; + +/// Create a copy of PasswordError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PasswordTooShortCopyWith get copyWith => _$PasswordTooShortCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordTooShort&&(identical(other.missing, missing) || other.missing == missing)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,missing); +} + +@override +String toString() { + return 'PasswordError.tooShort(missing: $missing)'; +} + + +} + +/// @nodoc +abstract mixin class $PasswordTooShortCopyWith<$Res> implements $PasswordErrorCopyWith<$Res> { + factory $PasswordTooShortCopyWith(PasswordTooShort value, $Res Function(PasswordTooShort) _then) = _$PasswordTooShortCopyWithImpl; +@useResult +$Res call({ + int missing +}); + + + + +} +/// @nodoc +class _$PasswordTooShortCopyWithImpl<$Res> + implements $PasswordTooShortCopyWith<$Res> { + _$PasswordTooShortCopyWithImpl(this._self, this._then); + + final PasswordTooShort _self; + final $Res Function(PasswordTooShort) _then; + +/// Create a copy of PasswordError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? missing = null,}) { + return _then(PasswordTooShort( +null == missing ? _self.missing : missing // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class PasswordCommon implements PasswordError { + const PasswordCommon(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordCommon); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'PasswordError.common()'; +} + + +} + + + + +// dart format on diff --git a/app/lib/features/auth/presentation/auth_failure_texts.dart b/app/lib/features/auth/presentation/auth_failure_texts.dart new file mode 100644 index 0000000..5471f57 --- /dev/null +++ b/app/lib/features/auth/presentation/auth_failure_texts.dart @@ -0,0 +1,38 @@ +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; + +/// User-facing text of an [AuthFailure] (Spec #1 § 4.4, texts from +/// `docs/design/auth-screens.md`). +/// +/// `emailSendLimited` has no text of its own: screens treat it as success +/// (F12) or convert it to `rateLimited` beforehand. +String authFailureMessage(AppLocalizations l10n, AuthFailure failure) => + switch (failure) { + InvalidCredentials() => l10n.authErrorInvalidCredentials, + EmailNotConfirmed() => l10n.authErrorEmailNotConfirmed, + EmailSendLimited() || RateLimited() => l10n.authErrorRateLimited, + WeakPassword(:final reasons) => weakPasswordMessage(l10n, reasons), + SamePassword() => l10n.authErrorSamePassword, + LinkInvalidOrExpired() => l10n.authErrorLinkInvalidOrExpired, + InvalidEmail() => l10n.authErrorEmailInvalid, + RegistrationRejected() => l10n.authErrorRegistrationRejected, + NetworkFailure() => l10n.authErrorNetwork, + Unexpected() => l10n.authErrorUnexpected, + }; + +/// One sentence per distinct reason; an empty list gets the generic text. +String weakPasswordMessage( + AppLocalizations l10n, + List reasons, +) { + final texts = { + for (final reason in reasons) + switch (reason) { + WeakPasswordReason.length => l10n.authErrorWeakPasswordLength, + WeakPasswordReason.pwned => l10n.authErrorWeakPasswordPwned, + WeakPasswordReason.characters || + WeakPasswordReason.unknown => l10n.authErrorWeakPasswordCharacters, + }, + }; + return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' '); +} diff --git a/app/lib/features/auth/presentation/auth_route_placeholder.dart b/app/lib/features/auth/presentation/auth_route_placeholder.dart new file mode 100644 index 0000000..0da02a7 --- /dev/null +++ b/app/lib/features/auth/presentation/auth_route_placeholder.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Placeholder for auth pages that 01b and 01c still deliver. Without visible +/// texts (no hard-coded UI strings). +class AuthRoutePlaceholder extends StatelessWidget { + const AuthRoutePlaceholder({required this.routeKey, super.key}); + + final String routeKey; + + @override + Widget build(BuildContext context) => + Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox()); +} + +GoRoute authPlaceholderRoute(String path, String key) => GoRoute( + path: path, + builder: (context, state) => AuthRoutePlaceholder(routeKey: key), +); diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart new file mode 100644 index 0000000..bee11e0 --- /dev/null +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -0,0 +1,38 @@ +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/features/auth/presentation/auth_route_placeholder.dart'; +import 'package:shuffle/features/auth/presentation/check_email/check_email_screen.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_screen.dart'; +import 'package:shuffle/features/auth/presentation/register/register_screen.dart'; + +/// Routes of the auth pages (Spec #1 § 5.3). Login, forgot and reset password +/// are placeholders until 01b and 01c. +final List authRoutes = [ + authPlaceholderRoute(AppRoutes.login, 'login'), + GoRoute( + path: AppRoutes.register, + builder: (context, state) => const RegisterScreen(), + routes: [ + GoRoute( + path: 'check-email', + builder: (context, state) { + // The address travels as `extra`, never in the URL. go_router would + // persist `extra` in the browser history; the router's + // `VolatileExtraCodec` prevents that. After a web reload it is gone; + // the screen then asks for it. + final extra = state.extra; + return CheckEmailScreen(email: extra is String ? extra : null); + }, + ), + ], + ), + authPlaceholderRoute(AppRoutes.forgotPassword, 'forgot_password'), + GoRoute( + path: AppRoutes.authConfirm, + builder: (context, state) => EmailLinkScreen( + tokenHash: state.uri.queryParameters['token_hash'], + type: state.uri.queryParameters['type'], + ), + ), + authPlaceholderRoute(AppRoutes.resetPassword, 'reset_password'), +]; diff --git a/app/lib/features/auth/presentation/check_email/check_email_screen.dart b/app/lib/features/auth/presentation/check_email/check_email_screen.dart new file mode 100644 index 0000000..5b532cc --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/check_email_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/config/auth_config.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// "Check your inbox" after the registration (`/register/check-email`, +/// KTO-01 Kriterium 1, 4 und 6). +/// +/// The text is neutral: it does not reveal whether the address already has an +/// account (Kriterium 4). [email] comes from the router `extra`; after a web +/// reload it is missing and the user types it in for "resend". +class CheckEmailScreen extends ConsumerStatefulWidget { + const CheckEmailScreen({this.email, super.key}); + + final String? email; + + @override + ConsumerState createState() => _CheckEmailScreenState(); +} + +class _CheckEmailScreenState extends ConsumerState { + final _emailController = TextEditingController(); + + bool get _hasKnownEmail => (widget.email ?? '').trim().isNotEmpty; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + void _resend() { + final email = _hasKnownEmail ? widget.email! : _emailController.text; + ref + .read(resendControllerProvider.notifier) + .resend(email, origin: ResendOrigin.checkEmail); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(resendControllerProvider); + final l10n = context.l10n; + final theme = Theme.of(context); + final failure = state.failure; + + return AppFormScaffold( + key: const Key('check_email_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authCheckEmailTitle), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.authCheckEmailBody, + key: const Key('check_email_body'), + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authCheckEmailValidity(_validityText(l10n)), + key: const Key('check_email_validity'), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authCheckEmailDelayHint, + key: const Key('check_email_delay_hint'), + style: theme.textTheme.bodyMedium, + ), + if (state.sent) ...[ + const SizedBox(height: AppSpacing.lg), + AppBanner( + key: const Key('check_email_resent_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: l10n.authCheckEmailResent, + ), + ], + if (failure != null) ...[ + const SizedBox(height: AppSpacing.lg), + AppBanner( + key: const Key('check_email_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + ), + ], + if (!_hasKnownEmail) ...[ + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('check_email_email'), + label: l10n.authFieldEmailLabel, + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + enabled: !state.isSending, + errorText: state.emailError == null + ? null + : l10n.authErrorEmailInvalid, + onChanged: (_) => + ref.read(resendControllerProvider.notifier).clearEmailError(), + onSubmitted: (_) => _resend(), + ), + ], + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('check_email_resend'), + label: l10n.authCheckEmailResend, + loadingLabel: l10n.authSubmitting, + isLoading: state.isSending, + onPressed: _resend, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('check_email_back_to_login'), + onPressed: () => context.go(AppRoutes.login), + child: Text(l10n.authBackToLogin), + ), + ], + ), + ); + } + + String _validityText(AppLocalizations l10n) { + assert( + AuthConfig.linkValidity == const Duration(hours: 1), + 'Update authCheckEmailValidityOneHour (AUTH-CFG-07 changed).', + ); + return l10n.authCheckEmailValidityOneHour; + } +} diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.dart b/app/lib/features/auth/presentation/check_email/resend_controller.dart new file mode 100644 index 0000000..3e43dae --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.dart @@ -0,0 +1,66 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'resend_controller.freezed.dart'; +part 'resend_controller.g.dart'; + +@freezed +abstract class ResendState with _$ResendState { + const factory ResendState({ + @Default(false) bool isSending, + + /// The last request counts as sent (also for `emailSendLimited` from the + /// check-email screen, F12). + @Default(false) bool sent, + AuthFailure? failure, + + /// Only for an address typed in by the user. + EmailError? emailError, + }) = _ResendState; +} + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). +@riverpod +class ResendController extends _$ResendController { + @override + ResendState build() => const ResendState(); + + void clearEmailError() { + if (state.emailError != null) { + state = state.copyWith(emailError: null); + } + } + + Future resend(String email, {required ResendOrigin origin}) async { + if (state.isSending) { + return; + } + final normalized = EmailFormat.normalize(email); + final emailError = EmailFormat.validate(normalized); + if (emailError != null) { + state = ResendState(emailError: emailError); + return; + } + + state = const ResendState(isSending: true); + final result = await ref + .read(authRepositoryProvider) + .resendConfirmation(normalized); + if (!ref.mounted) { + return; + } + final raw = result.errorOrNull; + final failure = raw == null ? null : resendFailureFor(raw, origin); + state = failure == null + ? const ResendState(sent: true) + : ResendState(failure: failure); + } +} diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart b/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart new file mode 100644 index 0000000..0fbffd2 --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.freezed.dart @@ -0,0 +1,318 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'resend_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ResendState { + + bool get isSending;/// The last request counts as sent (also for `emailSendLimited` from the +/// check-email screen, F12). + bool get sent; AuthFailure? get failure;/// Only for an address typed in by the user. + EmailError? get emailError; +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ResendStateCopyWith get copyWith => _$ResendStateCopyWithImpl(this as ResendState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as ResendState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is ResendState&&(identical(other.isSending, _this.isSending) || other.isSending == _this.isSending)&&(identical(other.sent, _this.sent) || other.sent == _this.sent)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)&&(identical(other.emailError, _this.emailError) || other.emailError == _this.emailError)); +} + + +@override +int get hashCode { + final _this = this as ResendState; + return Object.hash(runtimeType,_this.isSending,_this.sent,_this.failure,_this.emailError); +} + +@override +String toString() { + final _this = this as ResendState; + return 'ResendState(isSending: ${_this.isSending}, sent: ${_this.sent}, failure: ${_this.failure}, emailError: ${_this.emailError})'; +} + + +} + +/// @nodoc +abstract mixin class $ResendStateCopyWith<$Res> { + factory $ResendStateCopyWith(ResendState value, $Res Function(ResendState) _then) = _$ResendStateCopyWithImpl; +@useResult +$Res call({ + bool isSending, bool sent, AuthFailure? failure, EmailError? emailError +}); + + +$AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class _$ResendStateCopyWithImpl<$Res> + implements $ResendStateCopyWith<$Res> { + _$ResendStateCopyWithImpl(this._self, this._then); + + final ResendState _self; + final $Res Function(ResendState) _then; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) { + return _then(ResendState( +isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable +as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as EmailError?, + )); +} +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ResendState]. +extension ResendStatePatterns on ResendState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ResendState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ResendState value) $default,){ +final _that = this; +switch (_that) { +case _ResendState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ResendState value)? $default,){ +final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError) $default,) {final _that = this; +switch (_that) { +case _ResendState(): +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isSending, bool sent, AuthFailure? failure, EmailError? emailError)? $default,) {final _that = this; +switch (_that) { +case _ResendState() when $default != null: +return $default(_that.isSending,_that.sent,_that.failure,_that.emailError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ResendState implements ResendState { + const _ResendState({this.isSending = false, this.sent = false, this.failure, this.emailError}); + + +@override@JsonKey() final bool isSending; +/// The last request counts as sent (also for `emailSendLimited` from the +/// check-email screen, F12). +@override@JsonKey() final bool sent; +@override final AuthFailure? failure; +/// Only for an address typed in by the user. +@override final EmailError? emailError; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ResendStateCopyWith<_ResendState> get copyWith => __$ResendStateCopyWithImpl<_ResendState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ResendState&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.sent, sent) || other.sent == sent)&&(identical(other.failure, failure) || other.failure == failure)&&(identical(other.emailError, emailError) || other.emailError == emailError)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,isSending,sent,failure,emailError); +} + +@override +String toString() { + return 'ResendState(isSending: $isSending, sent: $sent, failure: $failure, emailError: $emailError)'; +} + + +} + +/// @nodoc +abstract mixin class _$ResendStateCopyWith<$Res> implements $ResendStateCopyWith<$Res> { + factory _$ResendStateCopyWith(_ResendState value, $Res Function(_ResendState) _then) = __$ResendStateCopyWithImpl; +@override @useResult +$Res call({ + bool isSending, bool sent, AuthFailure? failure, EmailError? emailError +}); + + +@override $AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class __$ResendStateCopyWithImpl<$Res> + implements _$ResendStateCopyWith<$Res> { + __$ResendStateCopyWithImpl(this._self, this._then); + + final _ResendState _self; + final $Res Function(_ResendState) _then; + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isSending = null,Object? sent = null,Object? failure = freezed,Object? emailError = freezed,}) { + return _then(_ResendState( +isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable +as bool,sent: null == sent ? _self.sent : sent // ignore: cast_nullable_to_non_nullable +as bool,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,emailError: freezed == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as EmailError?, + )); +} + +/// Create a copy of ResendState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/check_email/resend_controller.g.dart b/app/lib/features/auth/presentation/check_email/resend_controller.g.dart new file mode 100644 index 0000000..b342620 --- /dev/null +++ b/app/lib/features/auth/presentation/check_email/resend_controller.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'resend_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). + +@ProviderFor(ResendController) +final resendControllerProvider = ResendControllerProvider._(); + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). +final class ResendControllerProvider + extends $NotifierProvider { + /// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). + /// + /// The caller states where the request comes from: only there the controller + /// decides whether `emailSendLimited` is shown (login) or treated as success + /// (check-email screen). + ResendControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'resendControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$resendControllerHash(); + + @$internal + @override + ResendController create() => ResendController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ResendState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$resendControllerHash() => r'cab54661555bb641d3dc0fb1b06486b10f2e59e8'; + +/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12). +/// +/// The caller states where the request comes from: only there the controller +/// decides whether `emailSendLimited` is shown (login) or treated as success +/// (check-email screen). + +abstract class _$ResendController extends $Notifier { + ResendState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ResendState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.dart new file mode 100644 index 0000000..85d5f1d --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.dart @@ -0,0 +1,63 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_request.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'email_link_controller.freezed.dart'; +part 'email_link_controller.g.dart'; + +enum EmailLinkPhase { + /// Waiting for the click. + ready, + submitting, + success, + + /// Link missing, invalid, expired or used. + invalid, +} + +@freezed +abstract class EmailLinkState with _$EmailLinkState { + const factory EmailLinkState({ + required EmailLinkPhase phase, + EmailLinkRequest? request, + + /// Failure that allows another try (network, rate limit, unexpected). + AuthFailure? failure, + }) = _EmailLinkState; +} + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). +@riverpod +class EmailLinkController extends _$EmailLinkController { + @override + EmailLinkState build(String? tokenHash, String? type) { + final request = EmailLinkRequest.tryParse(tokenHash, type); + return request == null + ? const EmailLinkState(phase: EmailLinkPhase.invalid) + : EmailLinkState(phase: EmailLinkPhase.ready, request: request); + } + + Future confirm() async { + final request = state.request; + if (request == null || state.phase != EmailLinkPhase.ready) { + return; + } + state = state.copyWith(phase: EmailLinkPhase.submitting, failure: null); + final result = await ref + .read(authRepositoryProvider) + .verifyEmailLink(request.tokenHash, request.type); + if (!ref.mounted) { + return; + } + final failure = result.errorOrNull; + state = switch (failure) { + null => state.copyWith(phase: EmailLinkPhase.success), + LinkInvalidOrExpired() => state.copyWith(phase: EmailLinkPhase.invalid), + _ => state.copyWith(phase: EmailLinkPhase.ready, failure: failure), + }; + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart new file mode 100644 index 0000000..09bcba8 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.freezed.dart @@ -0,0 +1,311 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'email_link_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$EmailLinkState { + + EmailLinkPhase get phase; EmailLinkRequest? get request;/// Failure that allows another try (network, rate limit, unexpected). + AuthFailure? get failure; +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmailLinkStateCopyWith get copyWith => _$EmailLinkStateCopyWithImpl(this as EmailLinkState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as EmailLinkState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailLinkState&&(identical(other.phase, _this.phase) || other.phase == _this.phase)&&(identical(other.request, _this.request) || other.request == _this.request)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)); +} + + +@override +int get hashCode { + final _this = this as EmailLinkState; + return Object.hash(runtimeType,_this.phase,_this.request,_this.failure); +} + +@override +String toString() { + final _this = this as EmailLinkState; + return 'EmailLinkState(phase: ${_this.phase}, request: ${_this.request}, failure: ${_this.failure})'; +} + + +} + +/// @nodoc +abstract mixin class $EmailLinkStateCopyWith<$Res> { + factory $EmailLinkStateCopyWith(EmailLinkState value, $Res Function(EmailLinkState) _then) = _$EmailLinkStateCopyWithImpl; +@useResult +$Res call({ + EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure +}); + + +$AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class _$EmailLinkStateCopyWithImpl<$Res> + implements $EmailLinkStateCopyWith<$Res> { + _$EmailLinkStateCopyWithImpl(this._self, this._then); + + final EmailLinkState _self; + final $Res Function(EmailLinkState) _then; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) { + return _then(EmailLinkState( +phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable +as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable +as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?, + )); +} +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [EmailLinkState]. +extension EmailLinkStatePatterns on EmailLinkState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmailLinkState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmailLinkState value) $default,){ +final _that = this; +switch (_that) { +case _EmailLinkState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmailLinkState value)? $default,){ +final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that.phase,_that.request,_that.failure);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure) $default,) {final _that = this; +switch (_that) { +case _EmailLinkState(): +return $default(_that.phase,_that.request,_that.failure);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure)? $default,) {final _that = this; +switch (_that) { +case _EmailLinkState() when $default != null: +return $default(_that.phase,_that.request,_that.failure);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EmailLinkState implements EmailLinkState { + const _EmailLinkState({required this.phase, this.request, this.failure}); + + +@override final EmailLinkPhase phase; +@override final EmailLinkRequest? request; +/// Failure that allows another try (network, rate limit, unexpected). +@override final AuthFailure? failure; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmailLinkStateCopyWith<_EmailLinkState> get copyWith => __$EmailLinkStateCopyWithImpl<_EmailLinkState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmailLinkState&&(identical(other.phase, phase) || other.phase == phase)&&(identical(other.request, request) || other.request == request)&&(identical(other.failure, failure) || other.failure == failure)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,phase,request,failure); +} + +@override +String toString() { + return 'EmailLinkState(phase: $phase, request: $request, failure: $failure)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmailLinkStateCopyWith<$Res> implements $EmailLinkStateCopyWith<$Res> { + factory _$EmailLinkStateCopyWith(_EmailLinkState value, $Res Function(_EmailLinkState) _then) = __$EmailLinkStateCopyWithImpl; +@override @useResult +$Res call({ + EmailLinkPhase phase, EmailLinkRequest? request, AuthFailure? failure +}); + + +@override $AuthFailureCopyWith<$Res>? get failure; + +} +/// @nodoc +class __$EmailLinkStateCopyWithImpl<$Res> + implements _$EmailLinkStateCopyWith<$Res> { + __$EmailLinkStateCopyWithImpl(this._self, this._then); + + final _EmailLinkState _self; + final $Res Function(_EmailLinkState) _then; + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? phase = null,Object? request = freezed,Object? failure = freezed,}) { + return _then(_EmailLinkState( +phase: null == phase ? _self.phase : phase // ignore: cast_nullable_to_non_nullable +as EmailLinkPhase,request: freezed == request ? _self.request : request // ignore: cast_nullable_to_non_nullable +as EmailLinkRequest?,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as AuthFailure?, + )); +} + +/// Create a copy of EmailLinkState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart b/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart new file mode 100644 index 0000000..6c93756 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_controller.g.dart @@ -0,0 +1,130 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'email_link_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +@ProviderFor(EmailLinkController) +final emailLinkControllerProvider = EmailLinkControllerFamily._(); + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). +final class EmailLinkControllerProvider + extends $NotifierProvider { + /// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only + /// by [confirm] (a button press), never when the screen opens, so mail + /// scanners cannot use up the one-time token (Spec #1, E3). + EmailLinkControllerProvider._({ + required EmailLinkControllerFamily super.from, + required (String?, String?) super.argument, + }) : super( + retry: null, + name: r'emailLinkControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$emailLinkControllerHash(); + + @override + String toString() { + return r'emailLinkControllerProvider' + '' + '$argument'; + } + + @$internal + @override + EmailLinkController create() => EmailLinkController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EmailLinkState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is EmailLinkControllerProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$emailLinkControllerHash() => + r'90d3746ab8aa2ceb60122ee09c942cac6dd49249'; + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +final class EmailLinkControllerFamily extends $Family + with + $ClassFamilyOverride< + EmailLinkController, + EmailLinkState, + EmailLinkState, + EmailLinkState, + (String?, String?) + > { + EmailLinkControllerFamily._() + : super( + retry: null, + name: r'emailLinkControllerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only + /// by [confirm] (a button press), never when the screen opens, so mail + /// scanners cannot use up the one-time token (Spec #1, E3). + + EmailLinkControllerProvider call(String? tokenHash, String? type) => + EmailLinkControllerProvider._(argument: (tokenHash, type), from: this); + + @override + String toString() => r'emailLinkControllerProvider'; +} + +/// Redeems a mail link (KTO-01 Kriterium 1, 8). The link is redeemed only +/// by [confirm] (a button press), never when the screen opens, so mail +/// scanners cannot use up the one-time token (Spec #1, E3). + +abstract class _$EmailLinkController extends $Notifier { + late final _$args = ref.$arg as (String?, String?); + String? get tokenHash => _$args.$1; + String? get type => _$args.$2; + + EmailLinkState build(String? tokenHash, String? type); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + EmailLinkState, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args.$1, _$args.$2)); + } +} diff --git a/app/lib/features/auth/presentation/email_link/email_link_screen.dart b/app/lib/features/auth/presentation/email_link/email_link_screen.dart new file mode 100644 index 0000000..ceef8a4 --- /dev/null +++ b/app/lib/features/auth/presentation/email_link/email_link_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/platform/platform_info.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Landing page of the mail links (`/auth/confirm?token_hash=…&type=…`, +/// KTO-01 Kriterium 1 und 8). +/// +/// Redeeming needs a button press (Spec #1, E3): mail scanners that only open +/// the URL must not use up the one-time token. +class EmailLinkScreen extends ConsumerWidget { + const EmailLinkScreen({this.tokenHash, this.type, super.key}); + + final String? tokenHash; + final String? type; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = emailLinkControllerProvider(tokenHash, type); + final state = ref.watch(provider); + final controller = ref.read(provider.notifier); + final l10n = context.l10n; + final theme = Theme.of(context); + final linkType = state.request?.type ?? EmailLinkType.tryParse(type); + final isRecovery = linkType == EmailLinkType.recovery; + final title = isRecovery ? l10n.authRecoveryTitle : l10n.authConfirmTitle; + + final Widget content; + switch (state.phase) { + case EmailLinkPhase.invalid: + content = AppBanner( + key: const Key('email_link_invalid_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: l10n.authErrorLinkInvalidOrExpired, + actionLabel: l10n.authActionRequestNewLink, + onAction: () => context.go( + isRecovery ? AppRoutes.forgotPassword : AppRoutes.login, + ), + ); + case EmailLinkPhase.success: + content = _Success(isRecovery: isRecovery); + case EmailLinkPhase.ready || EmailLinkPhase.submitting: + final failure = state.failure; + content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (failure != null) ...[ + AppBanner( + key: const Key('email_link_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + ), + const SizedBox(height: AppSpacing.lg), + ], + Text( + isRecovery ? l10n.authRecoveryBody : l10n.authConfirmBody, + key: const Key('email_link_body'), + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('email_link_submit'), + label: isRecovery + ? l10n.authRecoverySubmit + : l10n.authConfirmSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: state.phase == EmailLinkPhase.submitting, + onPressed: controller.confirm, + ), + ], + ); + } + + return AppFormScaffold( + key: const Key('email_link_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(title), + const SizedBox(height: AppSpacing.lg), + content, + ], + ), + ); + } +} + +class _Success extends ConsumerWidget { + const _Success({required this.isRecovery}); + + final bool isRecovery; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final mobileWeb = ref.watch(isMobileWebProvider); + // Recovery continues automatically: the status change sends the user to + // the "new password" page (router redirect). + if (isRecovery) { + return const SizedBox.shrink(); + } + final message = mobileWeb + ? '${l10n.authConfirmSuccess} ${l10n.authConfirmSuccessMobileWeb}' + : l10n.authConfirmSuccess; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppBanner( + key: const Key('email_link_success_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: message, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('email_link_continue'), + label: l10n.authConfirmContinue, + onPressed: () => context.go(AppRoutes.home), + ), + ], + ); + } +} diff --git a/app/lib/features/auth/presentation/providers/auth_providers.dart b/app/lib/features/auth/presentation/providers/auth_providers.dart new file mode 100644 index 0000000..b15d406 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/auth_providers.dart @@ -0,0 +1,67 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/config/app_config_provider.dart'; +import 'package:shuffle/core/l10n/app_locale.dart'; +import 'package:shuffle/core/supabase/supabase_client_provider.dart'; +import 'package:shuffle/features/auth/data/asset_common_password_list.dart'; +import 'package:shuffle/features/auth/data/supabase_auth_repository.dart'; +import 'package:shuffle/features/auth/domain/auth_repository.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; + +part 'auth_providers.g.dart'; + +@Riverpod(keepAlive: true) +AuthRepository authRepository(Ref ref) { + final repository = SupabaseAuthRepository( + ref.watch(supabaseClientProvider).auth, + ); + ref.onDispose(repository.dispose); + return repository; +} + +/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen). +@Riverpod(keepAlive: true) +Stream authStatus(Ref ref) => + ref.watch(authRepositoryProvider).watchStatus(); + +Duration? _noRetry(int retryCount, Object error) => null; + +/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. +@Riverpod(keepAlive: true, retry: _noRetry) +Future commonPasswordList(Ref ref) => + const AssetCommonPasswordList().load(); + +/// Baut die Registrierungseingabe mit `terms_version` aus der [AppConfig] und +/// der aktiven Oberflächensprache als `locale` (F5, F6, KTO-11 Kriterium 11). +typedef RegistrationInputFactory = RegistrationInput Function({ + required String email, + required String password, + required String displayName, + required bool ageConfirmed, + required bool termsAccepted, +}); + +@riverpod +RegistrationInputFactory registrationInputFactory(Ref ref) { + final termsVersion = ref.watch(appConfigProvider).termsVersion; + final locale = ref.watch(appLocaleProvider).languageCode; + return ({ + required email, + required password, + required displayName, + required ageConfirmed, + required termsAccepted, + }) => RegistrationInput( + email: email, + password: password, + displayName: displayName, + ageConfirmed: ageConfirmed, + termsAccepted: termsAccepted, + termsVersion: termsVersion, + locale: locale, + ); +} diff --git a/app/lib/features/auth/presentation/providers/auth_providers.g.dart b/app/lib/features/auth/presentation/providers/auth_providers.g.dart new file mode 100644 index 0000000..9792b65 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/auth_providers.g.dart @@ -0,0 +1,198 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(authRepository) +final authRepositoryProvider = AuthRepositoryProvider._(); + +final class AuthRepositoryProvider + extends $FunctionalProvider + with $Provider { + AuthRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + AuthRepository create(Ref ref) { + return authRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authRepositoryHash() => r'a8f4563b2d07ee4f38024d50bed6e844adc95398'; + +/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen). + +@ProviderFor(authStatus) +final authStatusProvider = AuthStatusProvider._(); + +/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen). + +final class AuthStatusProvider + extends + $FunctionalProvider< + AsyncValue, + AuthStatus, + Stream + > + with $FutureModifier, $StreamProvider { + /// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen). + AuthStatusProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authStatusProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authStatusHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return authStatus(ref); + } +} + +String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293'; + +/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. + +@ProviderFor(commonPasswordList) +final commonPasswordListProvider = CommonPasswordListProvider._(); + +/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines +/// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: +/// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server +/// erzwingt sie ebenfalls), statt das Absenden zu blockieren. + +final class CommonPasswordListProvider + extends + $FunctionalProvider< + AsyncValue, + CommonPasswordList, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines + /// Passwortfeldes), nicht beim App-Start. Ohne automatische Wiederholung: + /// Schlägt das Laden fehl, prüft der Client nur die Länge (der Server + /// erzwingt sie ebenfalls), statt das Absenden zu blockieren. + CommonPasswordListProvider._() + : super( + from: null, + argument: null, + retry: _noRetry, + name: r'commonPasswordListProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$commonPasswordListHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return commonPasswordList(ref); + } +} + +String _$commonPasswordListHash() => + r'39cdcf2bcde25bc7f4720c77feb7119febd2927f'; + +@ProviderFor(registrationInputFactory) +final registrationInputFactoryProvider = RegistrationInputFactoryProvider._(); + +final class RegistrationInputFactoryProvider + extends + $FunctionalProvider< + RegistrationInputFactory, + RegistrationInputFactory, + RegistrationInputFactory + > + with $Provider { + RegistrationInputFactoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'registrationInputFactoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$registrationInputFactoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + RegistrationInputFactory create(Ref ref) { + return registrationInputFactory(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RegistrationInputFactory value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$registrationInputFactoryHash() => + r'31d8018fd695e874cbde8247727de1d92e92766a'; diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.dart b/app/lib/features/auth/presentation/providers/auth_redirect.dart new file mode 100644 index 0000000..e318d83 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/auth_redirect.dart @@ -0,0 +1,151 @@ +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/routing/redirect_source.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'auth_redirect.g.dart'; + +/// Seiten, die nur abgemeldete Nutzer sehen (angemeldet → Startseite bzw. +/// `from`). +const _guestOnlyPaths = { + AppRoutes.login, + AppRoutes.register, + AppRoutes.forgotPassword, +}; + +/// Pfade, die nie als `from`-Ziel zugelassen sind (Schleifenschutz). +const _neverFromPaths = {..._guestOnlyPaths, AppRoutes.resetPassword}; + +/// Redirect-Regeln der Auth-Story (Spec #1 § 5.3), rein und ohne Zustand. +/// +/// [privatePaths] sind Pfadpräfixe privater Routen. Sie ist bewusst eine +/// explizite Liste (derzeit leer): Alles andere ist öffentlich, unbekannte +/// Pfade landen weiter auf der 404-Seite. +String? authRedirectFor( + AuthStatus status, + Uri location, { + Iterable privatePaths = const [], +}) { + final path = location.path; + switch (status) { + case PasswordRecovery(): + return path == AppRoutes.resetPassword ? null : AppRoutes.resetPassword; + case SignedIn(): + if (path == AppRoutes.resetPassword) { + return AppRoutes.home; + } + if (_guestOnlyPaths.contains(path)) { + return safeFromPath(location.queryParameters[AppRoutes.fromParam]) ?? + AppRoutes.home; + } + return null; + case SignedOut(): + if (path == AppRoutes.resetPassword) { + return AppRoutes.login; + } + if (_isPrivate(path, privatePaths)) { + final target = location.hasQuery ? '$path?${location.query}' : path; + return Uri( + path: AppRoutes.login, + queryParameters: {AppRoutes.fromParam: target}, + ).toString(); + } + return null; + } +} + +bool _isPrivate(String path, Iterable privatePaths) => + privatePaths.any((prefix) => path == prefix || path.startsWith('$prefix/')); + +/// Prüft den `from`-Parameter: nur interne Pfade (beginnen mit `/`, nicht mit +/// `//` oder `/\`), keine Auth-Seiten. Sonst `null`. +String? safeFromPath(String? from) { + if (from == null || !from.startsWith('/')) { + return null; + } + if (from.startsWith('//') || from.startsWith(r'/\')) { + return null; + } + final uri = Uri.tryParse(from); + if (uri == null || uri.hasScheme || uri.hasAuthority) { + return null; + } + // `/login/` is the same page as `/login` (go_router ignores the trailing + // slash), so it is not allowed either. + final path = uri.path.length > 1 + ? uri.path.replaceFirst(RegExp(r'/+$'), '') + : uri.path; + if (_neverFromPaths.contains(path) || + path.startsWith(AppRoutes.authConfirm)) { + return null; + } + return from; +} + +/// [RedirectSource] mit Anmeldestatus für den Router. +class AuthRedirectSource implements RedirectSource { + AuthRedirectSource({required this.readStatus, this.privatePaths = const []}); + + final AuthStatus Function() readStatus; + final Iterable privatePaths; + final _changes = ValueNotifier(0); + + @override + Listenable get changes => _changes; + + /// Löst eine Neubewertung der Redirects aus. + void notifyStatusChanged() => _changes.value++; + + void dispose() => _changes.dispose(); + + @override + String? redirect(GoRouterState state) => + authRedirectFor(readStatus(), state.uri, privatePaths: privatePaths); +} + +/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider. +@Riverpod(keepAlive: true) +List privateRoutePaths(Ref ref) => const []; + +/// Aufräumen beim Abmelden (`ref.invalidate(...)` privater Provider). +typedef SignedOutHandler = void Function(Ref ref); + +/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider +/// und hängen ihre Handler an. +@Riverpod(keepAlive: true) +List signedOutHandlers(Ref ref) => const []; + +/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status +/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im +/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich +/// aus den Redirect-Regeln, sobald eine private Route offen ist. +@Riverpod(keepAlive: true) +AuthRedirectSource authRedirectSource(Ref ref) { + final source = AuthRedirectSource( + readStatus: () => ref.read(authRepositoryProvider).currentStatus, + privatePaths: ref.watch(privateRoutePathsProvider), + ); + ref + ..onDispose(source.dispose) + ..listen(authStatusProvider, (previous, next) { + final before = previous?.value; + final after = next.value; + if (before != null && before is! SignedOut && after is SignedOut) { + for (final handler in ref.read(signedOutHandlersProvider)) { + handler(ref); + } + } + source.notifyStatusChanged(); + }); + return source; +} + +/// Overrides, die die Auth-Redirects in den Router einhängen (in `main`). +List authOverrides() => [ + redirectSourceProvider.overrideWith( + (ref) => ref.watch(authRedirectSourceProvider), + ), +]; diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.g.dart b/app/lib/features/auth/presentation/providers/auth_redirect.g.dart new file mode 100644 index 0000000..20d1a50 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/auth_redirect.g.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_redirect.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider. + +@ProviderFor(privateRoutePaths) +final privateRoutePathsProvider = PrivateRoutePathsProvider._(); + +/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider. + +final class PrivateRoutePathsProvider + extends $FunctionalProvider, List, List> + with $Provider> { + /// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider. + PrivateRoutePathsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'privateRoutePathsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$privateRoutePathsHash(); + + @$internal + @override + $ProviderElement> $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + List create(Ref ref) { + return privateRoutePaths(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$privateRoutePathsHash() => r'21992f8c0bb0719b440ddc40c118e14d79617c29'; + +/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider +/// und hängen ihre Handler an. + +@ProviderFor(signedOutHandlers) +final signedOutHandlersProvider = SignedOutHandlersProvider._(); + +/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider +/// und hängen ihre Handler an. + +final class SignedOutHandlersProvider + extends + $FunctionalProvider< + List, + List, + List + > + with $Provider> { + /// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider + /// und hängen ihre Handler an. + SignedOutHandlersProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'signedOutHandlersProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$signedOutHandlersHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + List create(Ref ref) { + return signedOutHandlers(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$signedOutHandlersHash() => r'731ddc5c0c4d0aec1d144773b7aae23ce86321c8'; + +/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status +/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im +/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich +/// aus den Redirect-Regeln, sobald eine private Route offen ist. + +@ProviderFor(authRedirectSource) +final authRedirectSourceProvider = AuthRedirectSourceProvider._(); + +/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status +/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im +/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich +/// aus den Redirect-Regeln, sobald eine private Route offen ist. + +final class AuthRedirectSourceProvider + extends + $FunctionalProvider< + AuthRedirectSource, + AuthRedirectSource, + AuthRedirectSource + > + with $Provider { + /// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status + /// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im + /// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich + /// aus den Redirect-Regeln, sobald eine private Route offen ist. + AuthRedirectSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authRedirectSourceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authRedirectSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AuthRedirectSource create(Ref ref) { + return authRedirectSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthRedirectSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authRedirectSourceHash() => + r'c543c4be331cebf1f7e4865330293021265a336b'; diff --git a/app/lib/features/auth/presentation/register/register_controller.dart b/app/lib/features/auth/presentation/register/register_controller.dart new file mode 100644 index 0000000..3e75b6d --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_controller.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/config/app_config_provider.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; + +part 'register_controller.g.dart'; + +/// Legal texts linked from the consent checkbox. +enum LegalDocument { privacy, terms } + +final _log = AppLogger('RegisterController'); + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). +@riverpod +class RegisterController extends _$RegisterController { + Future? _commonPasswordsLoad; + + @override + RegisterState build() => const RegisterState(); + + void emailChanged(String value) { + state = state.copyWith(email: value, serverEmailInvalid: false); + } + + void passwordChanged(String value) { + // The password is checked while typing (Kriterium 3). + state = state.copyWith( + password: value, + visibleFields: {...state.visibleFields, RegisterField.password}, + serverPasswordReasons: null, + ); + unawaited(loadCommonPasswords()); + } + + void displayNameChanged(String value) { + state = state.copyWith(displayName: value); + } + + void ageConfirmedChanged({required bool value}) { + state = state.copyWith(ageConfirmed: value); + } + + void termsAcceptedChanged({required bool value}) { + state = state.copyWith(termsAccepted: value); + } + + /// Field left: show its error (text fields only). + void fieldBlurred(RegisterField field) { + if (field == RegisterField.ageConfirmation || + field == RegisterField.termsAcceptance) { + return; + } + state = state.copyWith(visibleFields: {...state.visibleFields, field}); + } + + /// Starts loading the list of common passwords (lazy, once). Called when + /// the password field is used. + Future loadCommonPasswords() => _commonPasswordsLoad ??= _load(); + + Future _load() async { + try { + final list = await ref.read(commonPasswordListProvider.future); + if (!ref.mounted) { + return; + } + state = state.copyWith(commonPasswords: list); + } on Object catch (error) { + // Fallback: only the length rule runs in the client; the server + // enforces the minimum length as well. + _log.warning('common password list unavailable: ${error.runtimeType}'); + } + } + + /// Validates and submits. Returns `true` if the screen should continue to + /// "check your inbox" (every 200 response, and `emailSendLimited`, F12). + Future submit() async { + if (state.isSubmitting) { + return false; + } + // Set before the first await, otherwise a second trigger (button and + // Enter key) passes the guard while the list is still loading. + state = state.copyWith(status: RegisterStatus.submitting); + await loadCommonPasswords(); + if (!ref.mounted) { + return false; + } + + state = state.copyWith( + visibleFields: RegisterField.values.toSet(), + bannerFailure: null, + serverPasswordReasons: null, + serverEmailInvalid: false, + focusField: null, + ); + final invalid = state.firstInvalidField; + if (invalid != null) { + state = state.copyWith( + status: RegisterStatus.editing, + focusField: invalid, + focusRequest: state.focusRequest + 1, + ); + return false; + } + + final input = ref.read(registrationInputFactoryProvider)( + email: EmailFormat.normalize(state.email), + password: state.password, + displayName: DisplayNameRule.normalize(state.displayName), + ageConfirmed: state.ageConfirmed, + termsAccepted: state.termsAccepted, + ); + final result = await ref.read(authRepositoryProvider).signUp(input); + if (!ref.mounted) { + return false; + } + + final failure = result.errorOrNull; + state = state.copyWith(status: RegisterStatus.editing); + if (failure == null || openEmailInputFailureFor(failure) == null) { + return true; + } + _showFailure(failure); + return false; + } + + void _showFailure(AuthFailure failure) { + switch (failure) { + case WeakPassword(:final reasons): + state = state.copyWith( + serverPasswordReasons: reasons, + focusField: RegisterField.password, + focusRequest: state.focusRequest + 1, + ); + case InvalidEmail(): + state = state.copyWith( + serverEmailInvalid: true, + focusField: RegisterField.email, + focusRequest: state.focusRequest + 1, + ); + default: + state = state.copyWith(bannerFailure: failure); + } + } + + /// Opens a legal text in the browser. `false` if that did not work. + Future openLegalDocument(LegalDocument document) async { + final config = ref.read(appConfigProvider); + final url = switch (document) { + LegalDocument.privacy => config.legalPrivacyUrl, + LegalDocument.terms => config.legalTermsUrl, + }; + final uri = Uri.tryParse(url); + if (uri == null || !isOpenableExternalUrl(uri)) { + _log.warning('invalid legal url for ${document.name}'); + return false; + } + return ref.read(urlOpenerProvider)(uri); + } +} diff --git a/app/lib/features/auth/presentation/register/register_controller.g.dart b/app/lib/features/auth/presentation/register/register_controller.g.dart new file mode 100644 index 0000000..5a223cb --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_controller.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'register_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + +@ProviderFor(RegisterController) +final registerControllerProvider = RegisterControllerProvider._(); + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). +final class RegisterControllerProvider + extends $NotifierProvider { + /// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + RegisterControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'registerControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$registerControllerHash(); + + @$internal + @override + RegisterController create() => RegisterController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RegisterState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$registerControllerHash() => + r'78a2a99ae36292286f26aff1f9910504bc464636'; + +/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). + +abstract class _$RegisterController extends $Notifier { + RegisterState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + RegisterState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart new file mode 100644 index 0000000..6b36515 --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_checkbox_field.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_password_field.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Registration (`/register`, KTO-01 Kriterien 1 bis 4 und 10, QUE-04 +/// Kriterium 1). Only shows state and forwards input to the +/// [RegisterController]. +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _displayName = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); + final _displayNameFocus = FocusNode(); + + /// The user is typing in the password field (see `liveError` below). + bool _passwordTyping = false; + + @override + void initState() { + super.initState(); + _emailFocus.addListener( + () => _onFocusChange(_emailFocus, RegisterField.email), + ); + _passwordFocus.addListener(() { + if (_passwordFocus.hasFocus) { + // The list of common passwords loads lazily on first use. + unawaited( + ref.read(registerControllerProvider.notifier).loadCommonPasswords(), + ); + } + _onFocusChange(_passwordFocus, RegisterField.password); + if (!_passwordFocus.hasFocus && _passwordTyping && mounted) { + // Field left: the length hint may be announced now. + setState(() => _passwordTyping = false); + } + }); + _displayNameFocus.addListener( + () => _onFocusChange(_displayNameFocus, RegisterField.displayName), + ); + } + + void _onFocusChange(FocusNode node, RegisterField field) { + if (!node.hasFocus) { + ref.read(registerControllerProvider.notifier).fieldBlurred(field); + } + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + _displayName.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + _displayNameFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final controller = ref.read(registerControllerProvider.notifier); + final succeeded = await controller.submit(); + if (!mounted || !succeeded) { + return; + } + final email = EmailFormat.normalize( + ref.read(registerControllerProvider).email, + ); + TextInput.finishAutofillContext(); + context.go(AppRoutes.registerCheckEmail, extra: email); + } + + Future _openLegal(LegalDocument document) async { + final opened = await ref + .read(registerControllerProvider.notifier) + .openLegalDocument(document); + if (!mounted || opened) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.authErrorUnexpected))); + } + + void _requestFocus(RegisterField? field) { + final node = switch (field) { + RegisterField.email => _emailFocus, + RegisterField.password => _passwordFocus, + RegisterField.displayName => _displayNameFocus, + _ => null, + }; + node?.requestFocus(); + } + + @override + Widget build(BuildContext context) { + ref.listen(registerControllerProvider, (previous, next) { + if (previous?.focusRequest != next.focusRequest) { + _requestFocus(next.focusField); + } + }); + final state = ref.watch(registerControllerProvider); + final controller = ref.read(registerControllerProvider.notifier); + final l10n = context.l10n; + final busy = state.isSubmitting; + + return AppFormScaffold( + key: const Key('register_screen'), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authRegisterTitle), + if (state.bannerFailure case final failure?) ...[ + const SizedBox(height: AppSpacing.lg), + _FailureBanner( + key: const Key('register_banner'), + failure: failure, + onRetry: busy ? null : _submit, + ), + ], + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('register_email'), + label: l10n.authFieldEmailLabel, + controller: _email, + focusNode: _emailFocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + errorText: _emailError(l10n, state), + onChanged: controller.emailChanged, + ), + const SizedBox(height: AppSpacing.lg), + AppPasswordField( + key: const Key('register_password'), + label: l10n.authFieldPasswordLabel, + showPasswordLabel: l10n.authPasswordShow, + hidePasswordLabel: l10n.authPasswordHide, + controller: _password, + focusNode: _passwordFocus, + isNewPassword: true, + textInputAction: TextInputAction.next, + enabled: !busy, + ruleHint: l10n.authPasswordRule, + errorText: _passwordError(l10n, state), + // "n missing" changes with every keystroke: announce it only + // when the field is left. The visible hint stays. + liveError: !_passwordTyping || !_isLengthHintOnly(state), + onChanged: (value) { + if (!_passwordTyping) { + setState(() => _passwordTyping = true); + } + controller.passwordChanged(value); + }, + ), + const SizedBox(height: AppSpacing.lg), + AppTextField( + key: const Key('register_display_name'), + label: l10n.authFieldDisplayNameLabel, + controller: _displayName, + focusNode: _displayNameFocus, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.nickname], + maxLength: DisplayNameRule.maxLength, + showCounter: true, + counterText: l10n.authDisplayNameCounter( + state.displayNameLength, + DisplayNameRule.maxLength, + ), + enabled: !busy, + errorText: _displayNameError(l10n, state), + onChanged: controller.displayNameChanged, + onSubmitted: (_) { + if (!busy) { + unawaited(_submit()); + } + }, + ), + const SizedBox(height: AppSpacing.lg), + AppCheckboxField( + key: const Key('register_age_checkbox'), + value: state.ageConfirmed, + label: l10n.authCheckboxAgeLabel, + enabled: !busy, + errorText: state.shownAgeError ? l10n.authAgeNotConfirmed : null, + onChanged: (value) => + controller.ageConfirmedChanged(value: value), + ), + AppCheckboxField.rich( + key: const Key('register_terms_checkbox'), + value: state.termsAccepted, + enabled: !busy, + segments: _termsSegments(l10n), + errorText: state.shownTermsError + ? l10n.authTermsNotAccepted + : null, + onChanged: (value) => + controller.termsAcceptedChanged(value: value), + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('register_submit'), + label: l10n.authRegisterSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: busy, + onPressed: _submit, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('register_login_link'), + onPressed: busy ? null : () => context.go(AppRoutes.login), + child: Text(l10n.authRegisterHaveAccount), + ), + ], + ), + ), + ); + } + + List _termsSegments(AppLocalizations l10n) { + final suffix = l10n.authCheckboxTermsSuffix; + return [ + AppLabelSegment.text('${l10n.authCheckboxTermsPrefix} '), + AppLabelSegment.link( + l10n.authCheckboxTermsPrivacyLink, + () => _openLegal(LegalDocument.privacy), + ), + AppLabelSegment.text(' ${l10n.authCheckboxTermsMiddle} '), + AppLabelSegment.link( + l10n.authCheckboxTermsTermsLink, + () => _openLegal(LegalDocument.terms), + ), + if (suffix.isNotEmpty) AppLabelSegment.text(' $suffix'), + ]; + } + + String? _emailError(AppLocalizations l10n, RegisterState state) { + if (state.serverEmailInvalid || state.shownEmailError != null) { + return l10n.authErrorEmailInvalid; + } + return null; + } + + /// The only password message shown is the "n missing" count (no server + /// answer, not "required"). + bool _isLengthHintOnly(RegisterState state) => + state.serverPasswordReasons == null && + !state.shownPasswordRequired && + state.shownPasswordError is PasswordTooShort; + + String? _passwordError(AppLocalizations l10n, RegisterState state) { + if (state.serverPasswordReasons case final reasons?) { + return weakPasswordMessage(l10n, reasons); + } + if (state.shownPasswordRequired) { + return l10n.authPasswordRequired; + } + return switch (state.shownPasswordError) { + PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), + PasswordCommon() => l10n.authPasswordCommon, + null => null, + }; + } + + String? _displayNameError(AppLocalizations l10n, RegisterState state) => + switch (state.shownDisplayNameError) { + DisplayNameError.empty => l10n.authDisplayNameEmpty, + DisplayNameError.tooLong => l10n.authDisplayNameTooLong( + DisplayNameRule.maxLength, + ), + // U+200B: same message as "invalid" (Spec #1 § 5.4). + DisplayNameError.invalidCharacter => l10n.authDisplayNameInvalid, + null => null, + }; +} + +class _FailureBanner extends StatelessWidget { + const _FailureBanner({ + required this.failure, + required this.onRetry, + super.key, + }); + + final AuthFailure failure; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final retryable = failure is NetworkFailure || failure is Unexpected; + return AppBanner( + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null, + onAction: retryable ? onRetry : null, + ); + } +} diff --git a/app/lib/features/auth/presentation/register/register_state.dart b/app/lib/features/auth/presentation/register/register_state.dart new file mode 100644 index 0000000..0005b68 --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_state.dart @@ -0,0 +1,115 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; + +part 'register_state.freezed.dart'; + +/// Form fields of the registration, in reading order. +enum RegisterField { + email, + password, + displayName, + ageConfirmation, + termsAcceptance, +} + +enum RegisterStatus { editing, submitting } + +/// Values, visibility of errors and submit state of the registration form. +/// +/// Errors are derived from the values by the domain rules. Which of them are +/// shown is decided by [visibleFields] (blur, typing in the password field, +/// submit attempt). +@freezed +abstract class RegisterState with _$RegisterState { + const factory RegisterState({ + @Default('') String email, + @Default('') String password, + @Default('') String displayName, + @Default(false) bool ageConfirmed, + @Default(false) bool termsAccepted, + @Default({}) Set visibleFields, + @Default(RegisterStatus.editing) RegisterStatus status, + + /// Loaded lazily with the first use of the password field. + CommonPasswordList? commonPasswords, + + /// General failure shown as banner. + AuthFailure? bannerFailure, + + /// `weak_password` reasons reported by the server, shown at the password. + List? serverPasswordReasons, + + /// The server rejected the address syntax; shown at the email field. + @Default(false) bool serverEmailInvalid, + + /// Field that should receive focus after a failed submit. + RegisterField? focusField, + + /// Incremented whenever [focusField] is set, so the screen can react + /// to repeated requests for the same field. + @Default(0) int focusRequest, + }) = _RegisterState; + + const RegisterState._(); + + bool get isSubmitting => status == RegisterStatus.submitting; + + EmailError? get emailError => EmailFormat.validate(email); + + PasswordError? get passwordError => + PasswordPolicy.validate(password, common: commonPasswords); + + DisplayNameError? get displayNameError => + DisplayNameRule.validate(displayName); + + int get displayNameLength => DisplayNameRule.length(displayName); + + bool get ageError => !ageConfirmed; + + bool get termsError => !termsAccepted; + + bool isVisible(RegisterField field) => visibleFields.contains(field); + + /// Local error of [field] (ignoring visibility). + bool hasError(RegisterField field) => switch (field) { + RegisterField.email => emailError != null, + RegisterField.password => password.isEmpty || passwordError != null, + RegisterField.displayName => displayNameError != null, + RegisterField.ageConfirmation => ageError, + RegisterField.termsAcceptance => termsError, + }; + + RegisterField? get firstInvalidField { + for (final field in RegisterField.values) { + if (hasError(field)) { + return field; + } + } + return null; + } + + // Errors as the screen shows them. + + EmailError? get shownEmailError => + isVisible(RegisterField.email) ? emailError : null; + + /// Empty password is "required", otherwise the policy error. + bool get shownPasswordRequired => + isVisible(RegisterField.password) && password.isEmpty; + + PasswordError? get shownPasswordError => + isVisible(RegisterField.password) ? passwordError : null; + + DisplayNameError? get shownDisplayNameError => + isVisible(RegisterField.displayName) ? displayNameError : null; + + bool get shownAgeError => + isVisible(RegisterField.ageConfirmation) && ageError; + + bool get shownTermsError => + isVisible(RegisterField.termsAcceptance) && termsError; +} diff --git a/app/lib/features/auth/presentation/register/register_state.freezed.dart b/app/lib/features/auth/presentation/register/register_state.freezed.dart new file mode 100644 index 0000000..3051f2a --- /dev/null +++ b/app/lib/features/auth/presentation/register/register_state.freezed.dart @@ -0,0 +1,368 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'register_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RegisterState { + + String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; Set get visibleFields; RegisterStatus get status;/// Loaded lazily with the first use of the password field. + CommonPasswordList? get commonPasswords;/// General failure shown as banner. + AuthFailure? get bannerFailure;/// `weak_password` reasons reported by the server, shown at the password. + List? get serverPasswordReasons;/// The server rejected the address syntax; shown at the email field. + bool get serverEmailInvalid;/// Field that should receive focus after a failed submit. + RegisterField? get focusField;/// Incremented whenever [focusField] is set, so the screen can react +/// to repeated requests for the same field. + int get focusRequest; +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RegisterStateCopyWith get copyWith => _$RegisterStateCopyWithImpl(this as RegisterState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as RegisterState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is RegisterState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.displayName, _this.displayName) || other.displayName == _this.displayName)&&(identical(other.ageConfirmed, _this.ageConfirmed) || other.ageConfirmed == _this.ageConfirmed)&&(identical(other.termsAccepted, _this.termsAccepted) || other.termsAccepted == _this.termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _this.visibleFields)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.commonPasswords, _this.commonPasswords) || other.commonPasswords == _this.commonPasswords)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _this.serverPasswordReasons)&&(identical(other.serverEmailInvalid, _this.serverEmailInvalid) || other.serverEmailInvalid == _this.serverEmailInvalid)&&(identical(other.focusField, _this.focusField) || other.focusField == _this.focusField)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest)); +} + + +@override +int get hashCode { + final _this = this as RegisterState; + return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest); +} + +@override +String toString() { + final _this = this as RegisterState; + return 'RegisterState(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, commonPasswords: ${_this.commonPasswords}, bannerFailure: ${_this.bannerFailure}, serverPasswordReasons: ${_this.serverPasswordReasons}, serverEmailInvalid: ${_this.serverEmailInvalid}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})'; +} + + +} + +/// @nodoc +abstract mixin class $RegisterStateCopyWith<$Res> { + factory $RegisterStateCopyWith(RegisterState value, $Res Function(RegisterState) _then) = _$RegisterStateCopyWithImpl; +@useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest +}); + + +$AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class _$RegisterStateCopyWithImpl<$Res> + implements $RegisterStateCopyWith<$Res> { + _$RegisterStateCopyWithImpl(this._self, this._then); + + final RegisterState _self; + final $Res Function(RegisterState) _then; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(RegisterState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,visibleFields: null == visibleFields ? _self.visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self.serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RegisterState]. +extension RegisterStatePatterns on RegisterState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RegisterState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RegisterState value) $default,){ +final _that = this; +switch (_that) { +case _RegisterState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RegisterState value)? $default,){ +final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest) $default,) {final _that = this; +switch (_that) { +case _RegisterState(): +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest)? $default,) {final _that = this; +switch (_that) { +case _RegisterState() when $default != null: +return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.visibleFields,_that.status,_that.commonPasswords,_that.bannerFailure,_that.serverPasswordReasons,_that.serverEmailInvalid,_that.focusField,_that.focusRequest);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RegisterState extends RegisterState { + const _RegisterState({this.email = '', this.password = '', this.displayName = '', this.ageConfirmed = false, this.termsAccepted = false, Set visibleFields = const {}, this.status = RegisterStatus.editing, this.commonPasswords, this.bannerFailure, List? serverPasswordReasons, this.serverEmailInvalid = false, this.focusField, this.focusRequest = 0}): _visibleFields = visibleFields,_serverPasswordReasons = serverPasswordReasons,super._(); + + +@override@JsonKey() final String email; +@override@JsonKey() final String password; +@override@JsonKey() final String displayName; +@override@JsonKey() final bool ageConfirmed; +@override@JsonKey() final bool termsAccepted; + final Set _visibleFields; +@override@JsonKey() Set get visibleFields { + if (_visibleFields is EqualUnmodifiableSetView) return _visibleFields; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_visibleFields); +} + +@override@JsonKey() final RegisterStatus status; +/// Loaded lazily with the first use of the password field. +@override final CommonPasswordList? commonPasswords; +/// General failure shown as banner. +@override final AuthFailure? bannerFailure; +/// `weak_password` reasons reported by the server, shown at the password. + final List? _serverPasswordReasons; +/// `weak_password` reasons reported by the server, shown at the password. +@override List? get serverPasswordReasons { + final value = _serverPasswordReasons; + if (value == null) return null; + if (_serverPasswordReasons is EqualUnmodifiableListView) return _serverPasswordReasons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// The server rejected the address syntax; shown at the email field. +@override@JsonKey() final bool serverEmailInvalid; +/// Field that should receive focus after a failed submit. +@override final RegisterField? focusField; +/// Incremented whenever [focusField] is set, so the screen can react +/// to repeated requests for the same field. +@override@JsonKey() final int focusRequest; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RegisterStateCopyWith<_RegisterState> get copyWith => __$RegisterStateCopyWithImpl<_RegisterState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegisterState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.ageConfirmed, ageConfirmed) || other.ageConfirmed == ageConfirmed)&&(identical(other.termsAccepted, termsAccepted) || other.termsAccepted == termsAccepted)&&const DeepCollectionEquality().equals(other.visibleFields, _visibleFields)&&(identical(other.status, status) || other.status == status)&&(identical(other.commonPasswords, commonPasswords) || other.commonPasswords == commonPasswords)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _serverPasswordReasons)&&(identical(other.serverEmailInvalid, serverEmailInvalid) || other.serverEmailInvalid == serverEmailInvalid)&&(identical(other.focusField, focusField) || other.focusField == focusField)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest); +} + +@override +String toString() { + return 'RegisterState(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, visibleFields: $visibleFields, status: $status, commonPasswords: $commonPasswords, bannerFailure: $bannerFailure, serverPasswordReasons: $serverPasswordReasons, serverEmailInvalid: $serverEmailInvalid, focusField: $focusField, focusRequest: $focusRequest)'; +} + + +} + +/// @nodoc +abstract mixin class _$RegisterStateCopyWith<$Res> implements $RegisterStateCopyWith<$Res> { + factory _$RegisterStateCopyWith(_RegisterState value, $Res Function(_RegisterState) _then) = __$RegisterStateCopyWithImpl; +@override @useResult +$Res call({ + String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, Set visibleFields, RegisterStatus status, CommonPasswordList? commonPasswords, AuthFailure? bannerFailure, List? serverPasswordReasons, bool serverEmailInvalid, RegisterField? focusField, int focusRequest +}); + + +@override $AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class __$RegisterStateCopyWithImpl<$Res> + implements _$RegisterStateCopyWith<$Res> { + __$RegisterStateCopyWithImpl(this._self, this._then); + + final _RegisterState _self; + final $Res Function(_RegisterState) _then; + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? visibleFields = null,Object? status = null,Object? commonPasswords = freezed,Object? bannerFailure = freezed,Object? serverPasswordReasons = freezed,Object? serverEmailInvalid = null,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(_RegisterState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable +as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable +as bool,visibleFields: null == visibleFields ? _self._visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RegisterStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverPasswordReasons: freezed == serverPasswordReasons ? _self._serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as RegisterField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of RegisterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/widgets/auth_screen_title.dart b/app/lib/features/auth/presentation/widgets/auth_screen_title.dart new file mode 100644 index 0000000..5125887 --- /dev/null +++ b/app/lib/features/auth/presentation/widgets/auth_screen_title.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// Screen title as heading for screen readers (design: `headlineSmall`). +class AuthScreenTitle extends StatelessWidget { + const AuthScreenTitle(this.title, {super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Semantics( + header: true, + child: Text(title, style: Theme.of(context).textTheme.headlineSmall), + ); + } +} diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index d71ccac..1997a07 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -4,5 +4,64 @@ "homePlaceholderMessage": "Willkommen bei Shuffle. Der Brettspielkatalog folgt in Kürze.", "notFoundTitle": "Seite nicht gefunden", "notFoundMessage": "Diese Seite existiert nicht oder wurde verschoben.", - "notFoundBackHome": "Zurück zum Start" + "notFoundBackHome": "Zurück zum Start", + "authErrorInvalidCredentials": "E-Mail-Adresse oder Passwort ist nicht korrekt.", + "authErrorEmailNotConfirmed": "Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.", + "authActionResendConfirmation": "Bestätigungs-E-Mail erneut senden", + "authErrorRateLimited": "Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.", + "authErrorWeakPasswordLength": "Das Passwort ist zu kurz.", + "authErrorWeakPasswordCharacters": "Das Passwort ist zu einfach. Wähle ein anderes.", + "authErrorWeakPasswordPwned": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", + "authErrorSamePassword": "Wähle ein anderes als dein bisheriges Passwort.", + "authErrorLinkInvalidOrExpired": "Der Link ist abgelaufen oder wurde schon benutzt.", + "authActionRequestNewLink": "Neuen Link anfordern", + "authErrorEmailInvalid": "Gib eine gültige E-Mail-Adresse ein.", + "authErrorRegistrationRejected": "Registrierung nicht möglich. Bitte prüfe deine Angaben.", + "authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.", + "authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "authActionRetry": "Erneut versuchen", + "authPasswordTooShort": "Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}.", + "authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", + "authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.", + "authPasswordRequired": "Gib ein Passwort ein.", + "authDisplayNameEmpty": "Gib einen Anzeigenamen ein.", + "authDisplayNameTooLong": "Der Anzeigename darf höchstens {max} Zeichen lang sein.", + "authDisplayNameInvalid": "Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.", + "authDisplayNameCounter": "{count}/{max}", + "authAgeNotConfirmed": "Shuffle ist erst ab 16 Jahren nutzbar.", + "authTermsNotAccepted": "Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.", + "authFieldEmailLabel": "E-Mail-Adresse", + "authFieldPasswordLabel": "Passwort", + "authFieldDisplayNameLabel": "Anzeigename", + "authPasswordShow": "Passwort anzeigen", + "authPasswordHide": "Passwort verbergen", + "authCheckboxAgeLabel": "Ich bin mindestens 16 Jahre alt", + "authCheckboxTermsPrefix": "Ich habe die", + "authCheckboxTermsPrivacyLink": "Datenschutzerklärung", + "authCheckboxTermsMiddle": "gelesen und stimme den", + "authCheckboxTermsTermsLink": "Nutzungsbedingungen", + "authCheckboxTermsSuffix": "zu", + "authBannerTypeError": "Fehler", + "authBannerTypeInfo": "Hinweis", + "authSubmitting": "Bitte warten …", + "authRegisterTitle": "Konto erstellen", + "authRegisterSubmit": "Konto erstellen", + "authRegisterHaveAccount": "Schon ein Konto? Anmelden", + "authCheckEmailTitle": "Prüfe dein Postfach", + "authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.", + "authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.", + "authCheckEmailValidity": "Der Link ist {duration} gültig.", + "authCheckEmailValidityOneHour": "1 Stunde", + "authCheckEmailResend": "E-Mail erneut senden", + "authCheckEmailResent": "Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.", + "authBackToLogin": "Zurück zur Anmeldung", + "authConfirmTitle": "E-Mail-Adresse bestätigen", + "authConfirmBody": "Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.", + "authConfirmSubmit": "E-Mail-Adresse bestätigen", + "authConfirmSuccess": "E-Mail bestätigt.", + "authConfirmSuccessMobileWeb": "Du kannst dich jetzt auch in der App anmelden.", + "authConfirmContinue": "Weiter", + "authRecoveryTitle": "Neues Passwort festlegen", + "authRecoveryBody": "Ein Klick genügt, um ein neues Passwort festzulegen.", + "authRecoverySubmit": "Weiter zum neuen Passwort" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 938ef0f..0dfc746 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -19,5 +19,95 @@ "notFoundBackHome": "Back to start", "@notFoundBackHome": { "description": "Button on the 404 page that navigates to the start page" - } + }, + "authErrorInvalidCredentials": "Email address or password is incorrect.", + "authErrorEmailNotConfirmed": "Please confirm your email address first. We sent you an email for this.", + "authActionResendConfirmation": "Resend confirmation email", + "authErrorRateLimited": "Too many attempts. Please wait a few minutes and try again.", + "authErrorWeakPasswordLength": "The password is too short.", + "authErrorWeakPasswordCharacters": "The password is too simple. Choose a different one.", + "authErrorWeakPasswordPwned": "This password is very common and easy to guess. Choose a different one.", + "authErrorSamePassword": "Choose a password different from your current one.", + "authErrorLinkInvalidOrExpired": "The link has expired or has already been used.", + "authActionRequestNewLink": "Request a new link", + "authErrorEmailInvalid": "Enter a valid email address.", + "authErrorRegistrationRejected": "Registration is not possible. Please check your details.", + "authErrorNetwork": "No connection. You need internet access for this step.", + "authErrorUnexpected": "Something went wrong. Please try again.", + "authActionRetry": "Try again", + "authPasswordTooShort": "At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.", + "@authPasswordTooShort": { + "placeholders": { + "missing": { + "type": "int" + } + } + }, + "authPasswordCommon": "This password is very common and easy to guess. Choose a different one.", + "authPasswordRule": "At least 10 characters. Not a very common password.", + "authPasswordRequired": "Enter a password.", + "authDisplayNameEmpty": "Enter a display name.", + "authDisplayNameTooLong": "The display name can be at most {max} characters long.", + "@authDisplayNameTooLong": { + "placeholders": { + "max": { + "type": "int" + } + } + }, + "authDisplayNameInvalid": "This display name is not allowed. Use visible characters.", + "authDisplayNameCounter": "{count}/{max}", + "@authDisplayNameCounter": { + "placeholders": { + "count": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "authAgeNotConfirmed": "Shuffle can only be used from age 16.", + "authTermsNotAccepted": "Please agree to the privacy policy and the terms of use.", + "authFieldEmailLabel": "Email address", + "authFieldPasswordLabel": "Password", + "authFieldDisplayNameLabel": "Display name", + "authPasswordShow": "Show password", + "authPasswordHide": "Hide password", + "authCheckboxAgeLabel": "I am at least 16 years old", + "authCheckboxTermsPrefix": "I have read the", + "authCheckboxTermsPrivacyLink": "privacy policy", + "authCheckboxTermsMiddle": "and agree to the", + "authCheckboxTermsTermsLink": "terms of use", + "authCheckboxTermsSuffix": "", + "authBannerTypeError": "Error", + "authBannerTypeInfo": "Note", + "authSubmitting": "Please wait …", + "authRegisterTitle": "Create account", + "authRegisterSubmit": "Create account", + "authRegisterHaveAccount": "Already have an account? Sign in", + "authCheckEmailTitle": "Check your inbox", + "authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.", + "authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.", + "authCheckEmailValidity": "The link is valid for {duration}.", + "@authCheckEmailValidity": { + "placeholders": { + "duration": { + "type": "String" + } + } + }, + "authCheckEmailValidityOneHour": "1 hour", + "authCheckEmailResend": "Resend email", + "authCheckEmailResent": "We resent the email, if there is something to send for this address.", + "authBackToLogin": "Back to sign in", + "authConfirmTitle": "Confirm email address", + "authConfirmBody": "One click is enough to confirm your email address.", + "authConfirmSubmit": "Confirm email address", + "authConfirmSuccess": "Email confirmed.", + "authConfirmSuccessMobileWeb": "You can now also sign in in the app.", + "authConfirmContinue": "Continue", + "authRecoveryTitle": "Set a new password", + "authRecoveryBody": "One click is enough to set a new password.", + "authRecoverySubmit": "Continue to new password" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 989bd53..b13789c 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -127,6 +127,360 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Back to start'** String get notFoundBackHome; + + /// No description provided for @authErrorInvalidCredentials. + /// + /// In en, this message translates to: + /// **'Email address or password is incorrect.'** + String get authErrorInvalidCredentials; + + /// No description provided for @authErrorEmailNotConfirmed. + /// + /// In en, this message translates to: + /// **'Please confirm your email address first. We sent you an email for this.'** + String get authErrorEmailNotConfirmed; + + /// No description provided for @authActionResendConfirmation. + /// + /// In en, this message translates to: + /// **'Resend confirmation email'** + String get authActionResendConfirmation; + + /// No description provided for @authErrorRateLimited. + /// + /// In en, this message translates to: + /// **'Too many attempts. Please wait a few minutes and try again.'** + String get authErrorRateLimited; + + /// No description provided for @authErrorWeakPasswordLength. + /// + /// In en, this message translates to: + /// **'The password is too short.'** + String get authErrorWeakPasswordLength; + + /// No description provided for @authErrorWeakPasswordCharacters. + /// + /// In en, this message translates to: + /// **'The password is too simple. Choose a different one.'** + String get authErrorWeakPasswordCharacters; + + /// No description provided for @authErrorWeakPasswordPwned. + /// + /// In en, this message translates to: + /// **'This password is very common and easy to guess. Choose a different one.'** + String get authErrorWeakPasswordPwned; + + /// No description provided for @authErrorSamePassword. + /// + /// In en, this message translates to: + /// **'Choose a password different from your current one.'** + String get authErrorSamePassword; + + /// No description provided for @authErrorLinkInvalidOrExpired. + /// + /// In en, this message translates to: + /// **'The link has expired or has already been used.'** + String get authErrorLinkInvalidOrExpired; + + /// No description provided for @authActionRequestNewLink. + /// + /// In en, this message translates to: + /// **'Request a new link'** + String get authActionRequestNewLink; + + /// No description provided for @authErrorEmailInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid email address.'** + String get authErrorEmailInvalid; + + /// No description provided for @authErrorRegistrationRejected. + /// + /// In en, this message translates to: + /// **'Registration is not possible. Please check your details.'** + String get authErrorRegistrationRejected; + + /// No description provided for @authErrorNetwork. + /// + /// In en, this message translates to: + /// **'No connection. You need internet access for this step.'** + String get authErrorNetwork; + + /// No description provided for @authErrorUnexpected. + /// + /// In en, this message translates to: + /// **'Something went wrong. Please try again.'** + String get authErrorUnexpected; + + /// No description provided for @authActionRetry. + /// + /// In en, this message translates to: + /// **'Try again'** + String get authActionRetry; + + /// No description provided for @authPasswordTooShort. + /// + /// In en, this message translates to: + /// **'At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.'** + String authPasswordTooShort(int missing); + + /// No description provided for @authPasswordCommon. + /// + /// In en, this message translates to: + /// **'This password is very common and easy to guess. Choose a different one.'** + String get authPasswordCommon; + + /// No description provided for @authPasswordRule. + /// + /// In en, this message translates to: + /// **'At least 10 characters. Not a very common password.'** + String get authPasswordRule; + + /// No description provided for @authPasswordRequired. + /// + /// In en, this message translates to: + /// **'Enter a password.'** + String get authPasswordRequired; + + /// No description provided for @authDisplayNameEmpty. + /// + /// In en, this message translates to: + /// **'Enter a display name.'** + String get authDisplayNameEmpty; + + /// No description provided for @authDisplayNameTooLong. + /// + /// In en, this message translates to: + /// **'The display name can be at most {max} characters long.'** + String authDisplayNameTooLong(int max); + + /// No description provided for @authDisplayNameInvalid. + /// + /// In en, this message translates to: + /// **'This display name is not allowed. Use visible characters.'** + String get authDisplayNameInvalid; + + /// No description provided for @authDisplayNameCounter. + /// + /// In en, this message translates to: + /// **'{count}/{max}'** + String authDisplayNameCounter(int count, int max); + + /// No description provided for @authAgeNotConfirmed. + /// + /// In en, this message translates to: + /// **'Shuffle can only be used from age 16.'** + String get authAgeNotConfirmed; + + /// No description provided for @authTermsNotAccepted. + /// + /// In en, this message translates to: + /// **'Please agree to the privacy policy and the terms of use.'** + String get authTermsNotAccepted; + + /// No description provided for @authFieldEmailLabel. + /// + /// In en, this message translates to: + /// **'Email address'** + String get authFieldEmailLabel; + + /// No description provided for @authFieldPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get authFieldPasswordLabel; + + /// No description provided for @authFieldDisplayNameLabel. + /// + /// In en, this message translates to: + /// **'Display name'** + String get authFieldDisplayNameLabel; + + /// No description provided for @authPasswordShow. + /// + /// In en, this message translates to: + /// **'Show password'** + String get authPasswordShow; + + /// No description provided for @authPasswordHide. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get authPasswordHide; + + /// No description provided for @authCheckboxAgeLabel. + /// + /// In en, this message translates to: + /// **'I am at least 16 years old'** + String get authCheckboxAgeLabel; + + /// No description provided for @authCheckboxTermsPrefix. + /// + /// In en, this message translates to: + /// **'I have read the'** + String get authCheckboxTermsPrefix; + + /// No description provided for @authCheckboxTermsPrivacyLink. + /// + /// In en, this message translates to: + /// **'privacy policy'** + String get authCheckboxTermsPrivacyLink; + + /// No description provided for @authCheckboxTermsMiddle. + /// + /// In en, this message translates to: + /// **'and agree to the'** + String get authCheckboxTermsMiddle; + + /// No description provided for @authCheckboxTermsTermsLink. + /// + /// In en, this message translates to: + /// **'terms of use'** + String get authCheckboxTermsTermsLink; + + /// No description provided for @authCheckboxTermsSuffix. + /// + /// In en, this message translates to: + /// **''** + String get authCheckboxTermsSuffix; + + /// No description provided for @authBannerTypeError. + /// + /// In en, this message translates to: + /// **'Error'** + String get authBannerTypeError; + + /// No description provided for @authBannerTypeInfo. + /// + /// In en, this message translates to: + /// **'Note'** + String get authBannerTypeInfo; + + /// No description provided for @authSubmitting. + /// + /// In en, this message translates to: + /// **'Please wait …'** + String get authSubmitting; + + /// No description provided for @authRegisterTitle. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authRegisterTitle; + + /// No description provided for @authRegisterSubmit. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authRegisterSubmit; + + /// No description provided for @authRegisterHaveAccount. + /// + /// In en, this message translates to: + /// **'Already have an account? Sign in'** + String get authRegisterHaveAccount; + + /// No description provided for @authCheckEmailTitle. + /// + /// In en, this message translates to: + /// **'Check your inbox'** + String get authCheckEmailTitle; + + /// No description provided for @authCheckEmailBody. + /// + /// In en, this message translates to: + /// **'We sent you an email if the address is not registered yet. Follow the link in it to continue.'** + String get authCheckEmailBody; + + /// No description provided for @authCheckEmailDelayHint. + /// + /// In en, this message translates to: + /// **'The email can take a few minutes. Also check your spam folder.'** + String get authCheckEmailDelayHint; + + /// No description provided for @authCheckEmailValidity. + /// + /// In en, this message translates to: + /// **'The link is valid for {duration}.'** + String authCheckEmailValidity(String duration); + + /// No description provided for @authCheckEmailValidityOneHour. + /// + /// In en, this message translates to: + /// **'1 hour'** + String get authCheckEmailValidityOneHour; + + /// No description provided for @authCheckEmailResend. + /// + /// In en, this message translates to: + /// **'Resend email'** + String get authCheckEmailResend; + + /// No description provided for @authCheckEmailResent. + /// + /// In en, this message translates to: + /// **'We resent the email, if there is something to send for this address.'** + String get authCheckEmailResent; + + /// No description provided for @authBackToLogin. + /// + /// In en, this message translates to: + /// **'Back to sign in'** + String get authBackToLogin; + + /// No description provided for @authConfirmTitle. + /// + /// In en, this message translates to: + /// **'Confirm email address'** + String get authConfirmTitle; + + /// No description provided for @authConfirmBody. + /// + /// In en, this message translates to: + /// **'One click is enough to confirm your email address.'** + String get authConfirmBody; + + /// No description provided for @authConfirmSubmit. + /// + /// In en, this message translates to: + /// **'Confirm email address'** + String get authConfirmSubmit; + + /// No description provided for @authConfirmSuccess. + /// + /// In en, this message translates to: + /// **'Email confirmed.'** + String get authConfirmSuccess; + + /// No description provided for @authConfirmSuccessMobileWeb. + /// + /// In en, this message translates to: + /// **'You can now also sign in in the app.'** + String get authConfirmSuccessMobileWeb; + + /// No description provided for @authConfirmContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get authConfirmContinue; + + /// No description provided for @authRecoveryTitle. + /// + /// In en, this message translates to: + /// **'Set a new password'** + String get authRecoveryTitle; + + /// No description provided for @authRecoveryBody. + /// + /// In en, this message translates to: + /// **'One click is enough to set a new password.'** + String get authRecoveryBody; + + /// No description provided for @authRecoverySubmit. + /// + /// In en, this message translates to: + /// **'Continue to new password'** + String get authRecoverySubmit; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 185ddf6..88e00cb 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -25,4 +25,216 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notFoundBackHome => 'Zurück zum Start'; + + @override + String get authErrorInvalidCredentials => + 'E-Mail-Adresse oder Passwort ist nicht korrekt.'; + + @override + String get authErrorEmailNotConfirmed => + 'Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt.'; + + @override + String get authActionResendConfirmation => + 'Bestätigungs-E-Mail erneut senden'; + + @override + String get authErrorRateLimited => + 'Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.'; + + @override + String get authErrorWeakPasswordLength => 'Das Passwort ist zu kurz.'; + + @override + String get authErrorWeakPasswordCharacters => + 'Das Passwort ist zu einfach. Wähle ein anderes.'; + + @override + String get authErrorWeakPasswordPwned => + 'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.'; + + @override + String get authErrorSamePassword => + 'Wähle ein anderes als dein bisheriges Passwort.'; + + @override + String get authErrorLinkInvalidOrExpired => + 'Der Link ist abgelaufen oder wurde schon benutzt.'; + + @override + String get authActionRequestNewLink => 'Neuen Link anfordern'; + + @override + String get authErrorEmailInvalid => 'Gib eine gültige E-Mail-Adresse ein.'; + + @override + String get authErrorRegistrationRejected => + 'Registrierung nicht möglich. Bitte prüfe deine Angaben.'; + + @override + String get authErrorNetwork => + 'Keine Verbindung. Für diesen Schritt brauchst du Internet.'; + + @override + String get authErrorUnexpected => + 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; + + @override + String get authActionRetry => 'Erneut versuchen'; + + @override + String authPasswordTooShort(int missing) { + String _temp0 = intl.Intl.pluralLogic( + missing, + locale: localeName, + other: 'es fehlen noch $missing Zeichen', + one: 'es fehlt noch 1 Zeichen', + ); + return 'Mindestens 10 Zeichen – $_temp0.'; + } + + @override + String get authPasswordCommon => + 'Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.'; + + @override + String get authPasswordRule => + 'Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.'; + + @override + String get authPasswordRequired => 'Gib ein Passwort ein.'; + + @override + String get authDisplayNameEmpty => 'Gib einen Anzeigenamen ein.'; + + @override + String authDisplayNameTooLong(int max) { + return 'Der Anzeigename darf höchstens $max Zeichen lang sein.'; + } + + @override + String get authDisplayNameInvalid => + 'Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen.'; + + @override + String authDisplayNameCounter(int count, int max) { + return '$count/$max'; + } + + @override + String get authAgeNotConfirmed => 'Shuffle ist erst ab 16 Jahren nutzbar.'; + + @override + String get authTermsNotAccepted => + 'Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.'; + + @override + String get authFieldEmailLabel => 'E-Mail-Adresse'; + + @override + String get authFieldPasswordLabel => 'Passwort'; + + @override + String get authFieldDisplayNameLabel => 'Anzeigename'; + + @override + String get authPasswordShow => 'Passwort anzeigen'; + + @override + String get authPasswordHide => 'Passwort verbergen'; + + @override + String get authCheckboxAgeLabel => 'Ich bin mindestens 16 Jahre alt'; + + @override + String get authCheckboxTermsPrefix => 'Ich habe die'; + + @override + String get authCheckboxTermsPrivacyLink => 'Datenschutzerklärung'; + + @override + String get authCheckboxTermsMiddle => 'gelesen und stimme den'; + + @override + String get authCheckboxTermsTermsLink => 'Nutzungsbedingungen'; + + @override + String get authCheckboxTermsSuffix => 'zu'; + + @override + String get authBannerTypeError => 'Fehler'; + + @override + String get authBannerTypeInfo => 'Hinweis'; + + @override + String get authSubmitting => 'Bitte warten …'; + + @override + String get authRegisterTitle => 'Konto erstellen'; + + @override + String get authRegisterSubmit => 'Konto erstellen'; + + @override + String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden'; + + @override + String get authCheckEmailTitle => 'Prüfe dein Postfach'; + + @override + String get authCheckEmailBody => + 'Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.'; + + @override + String get authCheckEmailDelayHint => + 'Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.'; + + @override + String authCheckEmailValidity(String duration) { + return 'Der Link ist $duration gültig.'; + } + + @override + String get authCheckEmailValidityOneHour => '1 Stunde'; + + @override + String get authCheckEmailResend => 'E-Mail erneut senden'; + + @override + String get authCheckEmailResent => + 'Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt.'; + + @override + String get authBackToLogin => 'Zurück zur Anmeldung'; + + @override + String get authConfirmTitle => 'E-Mail-Adresse bestätigen'; + + @override + String get authConfirmBody => + 'Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen.'; + + @override + String get authConfirmSubmit => 'E-Mail-Adresse bestätigen'; + + @override + String get authConfirmSuccess => 'E-Mail bestätigt.'; + + @override + String get authConfirmSuccessMobileWeb => + 'Du kannst dich jetzt auch in der App anmelden.'; + + @override + String get authConfirmContinue => 'Weiter'; + + @override + String get authRecoveryTitle => 'Neues Passwort festlegen'; + + @override + String get authRecoveryBody => + 'Ein Klick genügt, um ein neues Passwort festzulegen.'; + + @override + String get authRecoverySubmit => 'Weiter zum neuen Passwort'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 553cb30..14bab3a 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -24,4 +24,213 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notFoundBackHome => 'Back to start'; + + @override + String get authErrorInvalidCredentials => + 'Email address or password is incorrect.'; + + @override + String get authErrorEmailNotConfirmed => + 'Please confirm your email address first. We sent you an email for this.'; + + @override + String get authActionResendConfirmation => 'Resend confirmation email'; + + @override + String get authErrorRateLimited => + 'Too many attempts. Please wait a few minutes and try again.'; + + @override + String get authErrorWeakPasswordLength => 'The password is too short.'; + + @override + String get authErrorWeakPasswordCharacters => + 'The password is too simple. Choose a different one.'; + + @override + String get authErrorWeakPasswordPwned => + 'This password is very common and easy to guess. Choose a different one.'; + + @override + String get authErrorSamePassword => + 'Choose a password different from your current one.'; + + @override + String get authErrorLinkInvalidOrExpired => + 'The link has expired or has already been used.'; + + @override + String get authActionRequestNewLink => 'Request a new link'; + + @override + String get authErrorEmailInvalid => 'Enter a valid email address.'; + + @override + String get authErrorRegistrationRejected => + 'Registration is not possible. Please check your details.'; + + @override + String get authErrorNetwork => + 'No connection. You need internet access for this step.'; + + @override + String get authErrorUnexpected => 'Something went wrong. Please try again.'; + + @override + String get authActionRetry => 'Try again'; + + @override + String authPasswordTooShort(int missing) { + String _temp0 = intl.Intl.pluralLogic( + missing, + locale: localeName, + other: '$missing more characters needed', + one: '1 more character needed', + ); + return 'At least 10 characters – $_temp0.'; + } + + @override + String get authPasswordCommon => + 'This password is very common and easy to guess. Choose a different one.'; + + @override + String get authPasswordRule => + 'At least 10 characters. Not a very common password.'; + + @override + String get authPasswordRequired => 'Enter a password.'; + + @override + String get authDisplayNameEmpty => 'Enter a display name.'; + + @override + String authDisplayNameTooLong(int max) { + return 'The display name can be at most $max characters long.'; + } + + @override + String get authDisplayNameInvalid => + 'This display name is not allowed. Use visible characters.'; + + @override + String authDisplayNameCounter(int count, int max) { + return '$count/$max'; + } + + @override + String get authAgeNotConfirmed => 'Shuffle can only be used from age 16.'; + + @override + String get authTermsNotAccepted => + 'Please agree to the privacy policy and the terms of use.'; + + @override + String get authFieldEmailLabel => 'Email address'; + + @override + String get authFieldPasswordLabel => 'Password'; + + @override + String get authFieldDisplayNameLabel => 'Display name'; + + @override + String get authPasswordShow => 'Show password'; + + @override + String get authPasswordHide => 'Hide password'; + + @override + String get authCheckboxAgeLabel => 'I am at least 16 years old'; + + @override + String get authCheckboxTermsPrefix => 'I have read the'; + + @override + String get authCheckboxTermsPrivacyLink => 'privacy policy'; + + @override + String get authCheckboxTermsMiddle => 'and agree to the'; + + @override + String get authCheckboxTermsTermsLink => 'terms of use'; + + @override + String get authCheckboxTermsSuffix => ''; + + @override + String get authBannerTypeError => 'Error'; + + @override + String get authBannerTypeInfo => 'Note'; + + @override + String get authSubmitting => 'Please wait …'; + + @override + String get authRegisterTitle => 'Create account'; + + @override + String get authRegisterSubmit => 'Create account'; + + @override + String get authRegisterHaveAccount => 'Already have an account? Sign in'; + + @override + String get authCheckEmailTitle => 'Check your inbox'; + + @override + String get authCheckEmailBody => + 'We sent you an email if the address is not registered yet. Follow the link in it to continue.'; + + @override + String get authCheckEmailDelayHint => + 'The email can take a few minutes. Also check your spam folder.'; + + @override + String authCheckEmailValidity(String duration) { + return 'The link is valid for $duration.'; + } + + @override + String get authCheckEmailValidityOneHour => '1 hour'; + + @override + String get authCheckEmailResend => 'Resend email'; + + @override + String get authCheckEmailResent => + 'We resent the email, if there is something to send for this address.'; + + @override + String get authBackToLogin => 'Back to sign in'; + + @override + String get authConfirmTitle => 'Confirm email address'; + + @override + String get authConfirmBody => + 'One click is enough to confirm your email address.'; + + @override + String get authConfirmSubmit => 'Confirm email address'; + + @override + String get authConfirmSuccess => 'Email confirmed.'; + + @override + String get authConfirmSuccessMobileWeb => + 'You can now also sign in in the app.'; + + @override + String get authConfirmContinue => 'Continue'; + + @override + String get authRecoveryTitle => 'Set a new password'; + + @override + String get authRecoveryBody => 'One click is enough to set a new password.'; + + @override + String get authRecoverySubmit => 'Continue to new password'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 1772cf2..cfc4aa1 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,13 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shuffle/app.dart'; import 'package:shuffle/core/config/app_config.dart'; import 'package:shuffle/core/error/global_error_handler.dart'; +import 'package:shuffle/core/licenses/third_party_licenses.dart'; import 'package:shuffle/core/logging/app_logger.dart'; import 'package:shuffle/core/routing/url_strategy.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); AppLogger.configure(); + registerThirdPartyLicenses(); installGlobalErrorHandlers(); configurePathUrlStrategy(); @@ -19,5 +22,5 @@ Future main() async { publishableKey: config.supabaseAnonKey, ); - runApp(const ProviderScope(child: ShuffleApp())); + runApp(ProviderScope(overrides: authOverrides(), child: const ShuffleApp())); } diff --git a/app/pubspec.lock b/app/pubspec.lock index 6276c08..aedf2c8 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -334,7 +334,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -851,7 +851,7 @@ packages: source: hosted version: "1.4.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4d4030c..f57ecd5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: json_annotation: 4.12.0 logging: 1.3.0 intl: 0.20.3 + http: 1.6.0 + url_launcher: 6.3.3 dev_dependencies: flutter_test: @@ -37,3 +39,6 @@ dev_dependencies: flutter: uses-material-design: true generate: true + assets: + - assets/security/common-passwords.txt + - THIRD_PARTY.md diff --git a/app/test/core/config/auth_config_test.dart b/app/test/core/config/auth_config_test.dart new file mode 100644 index 0000000..f3eb23e --- /dev/null +++ b/app/test/core/config/auth_config_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/config/auth_config.dart'; + +void main() { + test('KTO-01 Kriterium 8, AUTH-CFG-07: link validity is 3600 s', () { + // The text "1 Stunde / 1 hour" (authCheckEmailValidityOneHour) is only + // correct as long as this holds. + expect(AuthConfig.linkValidity.inSeconds, 3600); + }); +} diff --git a/app/test/core/licenses/third_party_licenses_test.dart b/app/test/core/licenses/third_party_licenses_test.dart new file mode 100644 index 0000000..b283b7b --- /dev/null +++ b/app/test/core/licenses/third_party_licenses_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/licenses/third_party_licenses.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the SecLists MIT license shows up in the LicenseRegistry', () async { + registerThirdPartyLicenses(); + + final entries = await LicenseRegistry.licenses.toList(); + final entry = entries.firstWhere( + (e) => e.packages.contains(thirdPartyLicensePackage), + ); + final text = entry.paragraphs.map((p) => p.text).join('\n'); + expect(text, contains('MIT License')); + expect(text, contains('Copyright (c) 2018 Daniel Miessler')); + expect(text, contains('SecLists')); + }); + + test('the license asset is declared in the bundle', () async { + final text = await rootBundle.loadString(thirdPartyAsset); + expect(text, contains('Permission is hereby granted')); + }); +} diff --git a/app/test/core/platform/url_opener_test.dart b/app/test/core/platform/url_opener_test.dart new file mode 100644 index 0000000..de3af74 --- /dev/null +++ b/app/test/core/platform/url_opener_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; + +void main() { + group('guardedUrlOpener', () { + test('opens http and https URLs with a host', () async { + final launched = []; + final open = guardedUrlOpener((uri) async { + launched.add(uri); + return true; + }); + expect(await open(Uri.parse('https://shuffle.example/privacy')), isTrue); + expect(await open(Uri.parse('http://shuffle.example/terms')), isTrue); + expect(launched, hasLength(2)); + }); + + for (final url in [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'intent://scan/#Intent;scheme=zxing;end', + 'mailto:a@b.de', + 'https:///no-host', + '/relative/path', + 'data:text/html,hi', + ]) { + test('refuses $url', () async { + var launches = 0; + final open = guardedUrlOpener((uri) async { + launches++; + return true; + }); + expect(await open(Uri.parse(url)), isFalse); + expect(launches, 0); + }); + } + + test('returns false when launching throws', () async { + final open = guardedUrlOpener((uri) async => throw StateError('x')); + expect(await open(Uri.parse('https://shuffle.example')), isFalse); + }); + }); +} diff --git a/app/test/core/routing/volatile_extra_codec_test.dart b/app/test/core/routing/volatile_extra_codec_test.dart new file mode 100644 index 0000000..31e9851 --- /dev/null +++ b/app/test/core/routing/volatile_extra_codec_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/volatile_extra_codec.dart'; + +void main() { + test('extra is not serialized (nothing lands in the browser history)', () { + const codec = VolatileExtraCodec(); + expect(codec.encode('anna@example.com'), isNull); + expect(codec.decode('anna@example.com'), isNull); + }); + + testWidgets('go_router history state does not contain the extra', ( + tester, + ) async { + Object? seen; + final router = GoRouter( + extraCodec: const VolatileExtraCodec(), + initialLocation: '/a', + routes: [ + GoRoute(path: '/a', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/b', + builder: (_, state) { + seen = state.extra; + return const SizedBox(); + }, + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + router.go('/b', extra: 'anna@example.com'); + await tester.pumpAndSettle(); + + expect(seen, 'anna@example.com', reason: 'extra works in memory'); + final info = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + ); + expect(info?.state.toString(), isNot(contains('anna@example.com'))); + }); +} diff --git a/app/test/core/widgets/app_banner_test.dart b/app/test/core/widgets/app_banner_test.dart new file mode 100644 index 0000000..b3deaab --- /dev/null +++ b/app/test/core/widgets/app_banner_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppBanner', () { + testWidgets('shows message and type icon', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'Something failed', + ), + ); + expect(find.text('Something failed'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'FYI', + ), + ); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('is a live region with type prefix', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'Something failed', + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Error: Something failed')), + matchesSemantics(label: 'Error: Something failed', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('action calls back and is at least 48 dp', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'Confirm first', + actionLabel: 'Resend', + onAction: () => calls++, + ), + ); + await tester.tap(find.text('Resend')); + expect(calls, 1); + expect( + tester.getSize(find.byType(TextButton)).height, + greaterThanOrEqualTo(48), + ); + }); + + for (final mode in themeModes) { + for (final type in AppBannerType.values) { + testWidgets('meets a11y guidelines in $mode as $type', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppBanner( + type: type, + typeLabel: 'Note', + message: 'Confirm your address first', + actionLabel: 'Resend', + onAction: () {}, + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'A long message that has to wrap onto several lines here', + actionLabel: 'Resend confirmation email', + onAction: () {}, + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_checkbox_field_test.dart b/app/test/core/widgets/app_checkbox_field_test.dart new file mode 100644 index 0000000..d7dd22b --- /dev/null +++ b/app/test/core/widgets/app_checkbox_field_test.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_checkbox_field.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppCheckboxField', () { + testWidgets('tapping the label toggles the value', (tester) async { + bool? received; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'I am at least 16', + onChanged: (v) => received = v, + ), + ); + await tester.tap(find.text('I am at least 16')); + expect(received, isTrue); + }); + + testWidgets('tapping the checkbox toggles the value', (tester) async { + bool? received; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: true, + label: 'Label', + onChanged: (v) => received = v, + ), + ); + await tester.tap(find.byType(Checkbox)); + expect(received, isFalse); + }); + + testWidgets('does not react when disabled', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'Label', + enabled: false, + onChanged: (_) => calls++, + ), + ); + await tester.tap(find.text('Label')); + expect(calls, 0); + }); + + testWidgets('whole row is at least 48 dp high', (tester) async { + await pumpWidgetUnderTest( + tester, + AppCheckboxField(value: false, label: 'Short', onChanged: (_) {}), + ); + expect( + tester.getSize(find.byType(InkWell)).height, + greaterThanOrEqualTo(48), + ); + }); + + testWidgets('link tap opens the link and does not toggle', (tester) async { + var toggled = 0; + var opened = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (_) => toggled++, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () => opened++), + ], + ), + ); + await tester.tap(find.text('terms')); + expect(opened, 1); + expect(toggled, 0); + await tester.tapAt( + tester.getTopLeft(find.byType(AppCheckboxField)) + const Offset(30, 24), + ); + expect(toggled, 1); + }); + + testWidgets('exposes checked state and full label on the checkbox node', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: true, + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () {}), + ], + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('I accept the terms')), + matchesSemantics( + label: 'I accept the terms', + hasCheckedState: true, + isChecked: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + + testWidgets('every link is its own semantics node with isLink (web)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var privacy = 0; + var terms = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () => terms++), + const AppLabelSegment.text(' and the '), + AppLabelSegment.link('privacy policy', () => privacy++), + ], + ), + ); + for (final name in ['terms', 'privacy policy']) { + expect( + tester.getSemantics(find.bySemanticsLabel(name)), + matchesSemantics(label: name, isLink: true, hasTapAction: true), + ); + } + tester.semantics.tap(find.semantics.byLabel('terms')); + tester.semantics.tap(find.semantics.byLabel('privacy policy')); + expect(terms, 1); + expect(privacy, 1); + handle.dispose(); + }); + + testWidgets('error shows icon and text as live region, error border', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'Label', + errorText: 'Required', + onChanged: (_) {}, + ), + ); + expect(find.text('Required'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(tester.widget(find.byType(Checkbox)).isError, isTrue); + expect( + tester.getSemantics(find.text('Required')), + matchesSemantics(label: 'Required', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('keyboard focus reaches checkbox and link, space toggles', ( + tester, + ) async { + bool? received; + var opened = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (v) => received = v, + segments: [ + const AppLabelSegment.text('Accept '), + AppLabelSegment.link('terms', () => opened++), + ], + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(received, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(opened, 1); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode with error', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + errorText: 'Required', + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () {}), + ], + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + errorText: 'Please accept the privacy policy and the terms', + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I have read the privacy policy and '), + AppLabelSegment.link('accept the terms', () {}), + ], + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_form_scaffold_test.dart b/app/test/core/widgets/app_form_scaffold_test.dart new file mode 100644 index 0000000..3c5cd54 --- /dev/null +++ b/app/test/core/widgets/app_form_scaffold_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; + +import '../../helpers/pump_app.dart'; + +Future _pump( + WidgetTester tester, { + required Size size, + Widget? content, + double? textScale, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + textScale: textScale, + home: AppFormScaffold( + child: content ?? const SizedBox(key: Key('content'), height: 100), + ), + ); +} + +void main() { + group('AppFormScaffold', () { + testWidgets('compact: full width without card', (tester) async { + await _pump(tester, size: const Size(400, 800)); + expect(find.byType(Card), findsNothing); + expect( + tester.getSize(find.byKey(const Key('content'))).width, + 400 - 2 * AppSpacing.lg, + ); + }); + + testWidgets('just below 600 dp is still compact', (tester) async { + await _pump(tester, size: const Size(599, 800)); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('medium: centered card limited to authCardMaxWidth', ( + tester, + ) async { + await _pump(tester, size: const Size(700, 900)); + expect(find.byType(Card), findsOneWidget); + final card = tester.getRect(find.byType(Card)); + expect(card.width, AppBreakpoints.authCardMaxWidth); + expect(card.center.dx, closeTo(350, 0.5)); + }); + + testWidgets('expanded: card stays at authCardMaxWidth', (tester) async { + await _pump(tester, size: const Size(1280, 800)); + expect( + tester.getRect(find.byType(Card)).width, + AppBreakpoints.authCardMaxWidth, + ); + }); + + testWidgets('scrolls when the content is taller than the viewport', ( + tester, + ) async { + await _pump( + tester, + size: const Size(400, 400), + content: const Column( + children: [ + SizedBox(height: 900), + SizedBox(key: Key('end'), height: 40), + ], + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('end')), + 200, + scrollable: find.byType(Scrollable).first, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('keyboard inset shrinks the body and the form stays ' + 'scrollable', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await pumpApp( + tester, + home: const AppFormScaffold( + child: Column( + children: [ + SizedBox(height: 700), + TextField(key: Key('last')), + ], + ), + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('last')), + 100, + scrollable: find.byType(Scrollable).first, + ); + final bottom = tester.getBottomLeft(find.byKey(const Key('last'))).dy; + expect(bottom, lessThanOrEqualTo(500)); + }); + + testWidgets('does not overflow at 200 % text scale on a small phone', ( + tester, + ) async { + await _pump( + tester, + size: const Size(320, 480), + textScale: 2, + content: const Column( + children: [ + Text('A heading that is long enough to wrap around a few lines'), + SizedBox(height: 300), + ], + ), + ); + expect(tester.takeException(), isNull); + }); + + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('card meets contrast guideline in $mode', (tester) async { + final handle = tester.ensureSemantics(); + tester.view.physicalSize = const Size(800, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + themeMode: mode, + home: const AppFormScaffold(child: Text('Sign in')), + ); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + }); +} diff --git a/app/test/core/widgets/app_password_field_test.dart b/app/test/core/widgets/app_password_field_test.dart new file mode 100644 index 0000000..968e568 --- /dev/null +++ b/app/test/core/widgets/app_password_field_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_password_field.dart'; + +import 'widget_harness.dart'; + +Widget _field({ + String? ruleHint, + String? errorText, + bool isNewPassword = false, + TextEditingController? controller, +}) => AppPasswordField( + label: 'Password', + showPasswordLabel: 'Show password', + hidePasswordLabel: 'Hide password', + ruleHint: ruleHint, + errorText: errorText, + isNewPassword: isNewPassword, + controller: controller, +); + +bool _obscured(WidgetTester tester) => + tester.widget(find.byType(EditableText)).obscureText; + +void main() { + group('AppPasswordField', () { + testWidgets('is obscured initially and toggles visibility', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + expect(_obscured(tester), isTrue); + expect(find.byTooltip('Show password'), findsOneWidget); + + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect(_obscured(tester), isFalse); + expect(find.byTooltip('Hide password'), findsOneWidget); + + await tester.tap(find.byTooltip('Hide password')); + await tester.pump(); + expect(_obscured(tester), isTrue); + }); + + testWidgets('toggle keeps the entered text', (tester) async { + final controller = TextEditingController(text: 'secret-123'); + addTearDown(controller.dispose); + await pumpWidgetUnderTest(tester, _field(controller: controller)); + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect(controller.text, 'secret-123'); + }); + + testWidgets('toggle has a semantics label and a 48 dp target', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest(tester, _field()); + expect( + tester.getSemantics(find.byType(IconButton)).tooltip, + 'Show password', + ); + final size = tester.getSize(find.byType(IconButton)); + expect(size.width, greaterThanOrEqualTo(48)); + expect(size.height, greaterThanOrEqualTo(48)); + handle.dispose(); + }); + + testWidgets('uses password autofill hint by default', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + expect( + tester.widget(find.byType(EditableText)).autofillHints, + [AutofillHints.password], + ); + }); + + testWidgets('uses newPassword autofill hint for new passwords', ( + tester, + ) async { + await pumpWidgetUnderTest(tester, _field(isNewPassword: true)); + expect( + tester.widget(find.byType(EditableText)).autofillHints, + [AutofillHints.newPassword], + ); + }); + + testWidgets('disables autocorrect and suggestions', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + final editable = tester.widget(find.byType(EditableText)); + expect(editable.autocorrect, isFalse); + expect(editable.enableSuggestions, isFalse); + }); + + testWidgets('shows rule hint, error replaces it', (tester) async { + await pumpWidgetUnderTest(tester, _field(ruleHint: 'At least 10')); + expect(find.text('At least 10'), findsOneWidget); + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10', errorText: 'Too short'), + ); + expect(find.text('Too short'), findsOneWidget); + expect(find.text('At least 10'), findsNothing); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10', errorText: 'Too short'), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10 characters, no common passwords'), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_primary_button_test.dart b/app/test/core/widgets/app_primary_button_test.dart new file mode 100644 index 0000000..4447e38 --- /dev/null +++ b/app/test/core/widgets/app_primary_button_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppPrimaryButton', () { + testWidgets('calls onPressed', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () => calls++), + ); + await tester.tap(find.text('Register')); + expect(calls, 1); + }); + + testWidgets('is disabled and shows a spinner while loading', ( + tester, + ) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Register', + isLoading: true, + onPressed: () => calls++, + ), + settle: false, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.tap(find.text('Register'), warnIfMissed: false); + expect(calls, 0); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('is disabled without onPressed', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppPrimaryButton(label: 'Register', onPressed: null), + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('semantics: button with label, loading label while loading', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () {}), + ); + expect( + tester.getSemantics(find.byType(FilledButton)), + matchesSemantics( + label: 'Register', + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + ), + ); + + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Register', + loadingLabel: 'Registering', + isLoading: true, + onPressed: () {}, + ), + settle: false, + ); + expect( + tester.getSemantics(find.byType(FilledButton)), + matchesSemantics( + label: 'Registering', + isButton: true, + hasEnabledState: true, + ), + ); + handle.dispose(); + }); + + testWidgets('is at least 48 dp high and full width by default', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Go', onPressed: () {}), + ); + final size = tester.getSize(find.byType(FilledButton)); + expect(size.height, greaterThanOrEqualTo(48)); + expect(size.width, 400 - 32); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () {}), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Create account and confirm email address', + isLoading: true, + onPressed: () {}, + ), + textScale: 2, + size: const Size(320, 640), + settle: false, + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_text_field_test.dart b/app/test/core/widgets/app_text_field_test.dart new file mode 100644 index 0000000..9edb4f8 --- /dev/null +++ b/app/test/core/widgets/app_text_field_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppTextField', () { + testWidgets('shows label and helper text, error replaces helper', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Email', helperText: 'Helper'), + ); + expect(find.text('Email'), findsOneWidget); + expect(find.text('Helper'), findsOneWidget); + + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + helperText: 'Helper', + errorText: 'Broken', + ), + ); + expect(find.text('Broken'), findsOneWidget); + expect(find.text('Helper'), findsNothing); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('counterText replaces the default counter', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Name', + maxLength: 30, + showCounter: true, + counterText: '2/30', + ), + ); + expect(find.text('2/30'), findsOneWidget); + expect(find.text('0/30'), findsNothing); + }); + + testWidgets('exposes the label as semantics label of the text field', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest(tester, const AppTextField(label: 'Email')); + expect( + tester.getSemantics(find.byType(TextField)), + matchesSemantics( + label: 'Email', + isTextField: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('announces the error as live region', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Email', errorText: 'Broken'), + ); + expect( + tester.getSemantics(find.text('Broken')), + matchesSemantics(label: 'Broken', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('liveError false keeps the error visible but silent', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + errorText: 'Broken', + liveError: false, + ), + ); + expect(find.text('Broken'), findsOneWidget); + expect( + tester.getSemantics(find.text('Broken')), + matchesSemantics(label: 'Broken', isLiveRegion: false), + ); + handle.dispose(); + }); + + testWidgets('reports changes and keeps input', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + String? last; + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Name', + controller: controller, + onChanged: (v) => last = v, + ), + ); + await tester.enterText(find.byType(TextField), 'abc'); + expect(last, 'abc'); + expect(controller.text, 'abc'); + }); + + testWidgets('does not block input beyond maxLength, shows counter', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Name', + controller: controller, + maxLength: 5, + showCounter: true, + ), + ); + await tester.enterText(find.byType(TextField), 'abcdefg'); + await tester.pump(); + expect(controller.text, 'abcdefg'); + expect(find.text('7/5'), findsOneWidget); + }); + + testWidgets('hides the counter by default', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Name', maxLength: 5), + ); + await tester.enterText(find.byType(TextField), 'ab'); + await tester.pump(); + expect(find.text('2/5'), findsNothing); + }); + + testWidgets('applies input formatters', (tester) async { + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Digits', + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ); + await tester.enterText(find.byType(TextField), 'a1b2'); + expect( + tester.widget(find.byType(TextField)).controller?.text, + isNot('a1b2'), + ); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode with error', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + helperText: 'Helper', + errorText: 'Broken', + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale with long texts', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'A rather long label that needs to wrap over many lines', + helperText: 'A long helper text that also needs to wrap around', + errorText: 'A long error text that also needs to wrap around lines', + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/widget_harness.dart b/app/test/core/widgets/widget_harness.dart new file mode 100644 index 0000000..9e4bd4c --- /dev/null +++ b/app/test/core/widgets/widget_harness.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/pump_app.dart'; + +/// Pumps [child] in a scrollable Scaffold with the app theme. +Future pumpWidgetUnderTest( + WidgetTester tester, + Widget child, { + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + bool settle = true, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + themeMode: themeMode, + textScale: textScale, + settle: settle, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); +} + +const themeModes = [ThemeMode.light, ThemeMode.dark]; diff --git a/app/test/features/auth/data/auth_failure_mapper_test.dart b/app/test/features/auth/data/auth_failure_mapper_test.dart new file mode 100644 index 0000000..c018e03 --- /dev/null +++ b/app/test/features/auth/data/auth_failure_mapper_test.dart @@ -0,0 +1,266 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:shuffle/features/auth/data/auth_failure_mapper.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; + +AuthApiException _api(String code, {String status = '400'}) => + AuthApiException('msg', statusCode: status, code: code); + +const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}'; + +void main() { + group('Spec #1 § 4.4 error mapping', () { + test('KTO-01 Kriterium 7: invalid_credentials', () { + expect( + mapAuthError(_api('invalid_credentials'), AuthOperation.signIn), + const AuthFailure.invalidCredentials(), + ); + }); + + test('KTO-01 Kriterium 6: email_not_confirmed', () { + expect( + mapAuthError(_api('email_not_confirmed'), AuthOperation.signIn), + const AuthFailure.emailNotConfirmed(), + ); + }); + + test('KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited ' + 'for signUp, reset and resend', () { + for (final op in [ + AuthOperation.signUp, + AuthOperation.requestPasswordReset, + AuthOperation.resend, + ]) { + expect( + mapAuthError(_api('over_email_send_rate_limit', status: '429'), op), + const AuthFailure.emailSendLimited(), + reason: op.name, + ); + } + }); + + test('over_email_send_rate_limit elsewhere is rateLimited', () { + expect( + mapAuthError( + _api('over_email_send_rate_limit', status: '429'), + AuthOperation.updatePassword, + ), + const AuthFailure.rateLimited(), + ); + }); + + test('KTO-01 Kriterium 7: over_request_rate_limit -> rateLimited', () { + expect( + mapAuthError( + _api('over_request_rate_limit', status: '429'), + AuthOperation.signIn, + ), + const AuthFailure.rateLimited(), + ); + }); + + test('any other HTTP 429 (also non-JSON body) -> rateLimited', () { + expect( + mapAuthError( + const AuthApiException('slow down', statusCode: '429'), + AuthOperation.signIn, + ), + const AuthFailure.rateLimited(), + ); + }); + + test('KTO-01 Kriterium 3: AuthWeakPasswordException with reasons', () { + expect( + mapAuthError( + AuthWeakPasswordException( + message: 'weak', + statusCode: '422', + reasons: const ['length', 'characters', 'pwned', 'other'], + ), + AuthOperation.signUp, + ), + const AuthFailure.weakPassword([ + WeakPasswordReason.length, + WeakPasswordReason.characters, + WeakPasswordReason.pwned, + WeakPasswordReason.unknown, + ]), + ); + }); + + test('weak_password code on a plain API exception', () { + expect( + mapAuthError( + _api('weak_password', status: '422'), + AuthOperation.updatePassword, + ), + const AuthFailure.weakPassword([]), + ); + }); + + test('same_password', () { + expect( + mapAuthError( + _api('same_password', status: '422'), + AuthOperation.updatePassword, + ), + const AuthFailure.samePassword(), + ); + }); + + test('KTO-01 Kriterium 8: otp_expired -> linkInvalidOrExpired', () { + expect( + mapAuthError( + _api('otp_expired', status: '403'), + AuthOperation.verifyLink, + ), + const AuthFailure.linkInvalidOrExpired(), + ); + }); + + test('KTO-01 Kriterium 8: 403 without known code on verifyOTP', () { + expect( + mapAuthError( + const AuthApiException('invalid', statusCode: '403'), + AuthOperation.verifyLink, + ), + const AuthFailure.linkInvalidOrExpired(), + ); + expect( + mapAuthError( + const AuthApiException('forbidden', statusCode: '403'), + AuthOperation.signIn, + ), + const AuthFailure.unexpected(), + ); + }); + + test('email_address_invalid and validation_failed -> invalidEmail', () { + expect( + mapAuthError(_api('email_address_invalid'), AuthOperation.signUp), + const AuthFailure.invalidEmail(), + ); + expect( + mapAuthError( + _api('validation_failed', status: '422'), + AuthOperation.requestPasswordReset, + ), + const AuthFailure.invalidEmail(), + ); + }); + + test('KTO-01 Kriterium 2, 10: HTTP 500 on signUp (trigger error) -> ' + 'registrationRejected, decided by status only', () { + final error = AuthRetryableFetchException( + message: _triggerBody, + statusCode: '500', + ); + expect(error.code, isNull); + expect( + mapAuthError(error, AuthOperation.signUp), + const AuthFailure.registrationRejected( + reason: RegistrationRejectionReason.displayName, + ), + ); + }); + + test('HTTP 500 on signUp with unknown body keeps registrationRejected', () { + expect( + mapAuthError( + AuthRetryableFetchException(message: 'oops', statusCode: '500'), + AuthOperation.signUp, + ), + const AuthFailure.registrationRejected(), + ); + }); + + test('trigger messages map to reasons', () { + expect( + registrationRejectionReasonFrom('age_confirmation_required'), + RegistrationRejectionReason.ageConfirmation, + ); + expect( + registrationRejectionReasonFrom('terms_acceptance_required'), + RegistrationRejectionReason.termsAcceptance, + ); + expect( + registrationRejectionReasonFrom('terms_version_required'), + RegistrationRejectionReason.termsVersion, + ); + }); + + test('HTTP 500 outside signUp is a network failure', () { + expect( + mapAuthError( + AuthRetryableFetchException(message: _triggerBody, statusCode: '500'), + AuthOperation.signIn, + ), + const AuthFailure.network(), + ); + }); + + test('other 5xx on signUp (gateway) -> network', () { + expect( + mapAuthError( + AuthRetryableFetchException( + message: 'bad gateway', + statusCode: '502', + ), + AuthOperation.signUp, + ), + const AuthFailure.network(), + ); + }); + + test('Zustände Krit. 2: AuthRetryableFetchException without status', () { + expect( + mapAuthError( + AuthRetryableFetchException(message: 'Failed host lookup'), + AuthOperation.signIn, + ), + const AuthFailure.network(), + ); + }); + + test('TimeoutException and raw transport errors -> network', () { + expect( + mapAuthError(TimeoutException('t'), AuthOperation.signIn), + const AuthFailure.network(), + ); + expect( + mapAuthError(const SocketException('x'), AuthOperation.signIn), + const AuthFailure.network(), + ); + expect( + mapAuthError(http.ClientException('x'), AuthOperation.signIn), + const AuthFailure.network(), + ); + expect( + mapAuthError(const HandshakeException('x'), AuthOperation.signIn), + const AuthFailure.network(), + ); + }); + + test('everything else -> unexpected', () { + expect( + mapAuthError(StateError('x'), AuthOperation.signIn), + const AuthFailure.unexpected(), + ); + expect( + mapAuthError(_api('something_new'), AuthOperation.signIn), + const AuthFailure.unexpected(), + ); + expect( + mapAuthError( + AuthSessionMissingException(), + AuthOperation.updatePassword, + ), + const AuthFailure.unexpected(), + ); + }); + }); +} diff --git a/app/test/features/auth/data/auth_status_tracker_test.dart b/app/test/features/auth/data/auth_status_tracker_test.dart new file mode 100644 index 0000000..5a3304a --- /dev/null +++ b/app/test/features/auth/data/auth_status_tracker_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/data/auth_status_tracker.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; + +import 'auth_test_support.dart'; + +void main() { + group('KTO-01 Kriterium 5: AuthStatusTracker event mapping', () { + test('no session -> signedOut', () { + final tracker = AuthStatusTracker(); + expect(tracker.statusFor(null), const AuthStatus.signedOut()); + expect( + tracker.handle(AuthChangeEvent.initialSession, null), + const AuthStatus.signedOut(), + ); + }); + + test( + 'signedIn, tokenRefreshed, userUpdated, initialSession -> signedIn', + () { + final tracker = AuthStatusTracker(); + const user = AuthUser( + id: 'user-1', + email: 'anna@example.com', + emailConfirmed: true, + ); + for (final event in [ + AuthChangeEvent.initialSession, + AuthChangeEvent.signedIn, + AuthChangeEvent.tokenRefreshed, + AuthChangeEvent.userUpdated, + ]) { + expect( + tracker.handle(event, testSession()), + const AuthStatus.signedIn(user), + reason: event.name, + ); + } + }, + ); + + test('unconfirmed email is reflected in the user', () { + final status = AuthStatusTracker().handle( + AuthChangeEvent.signedIn, + testSession(user: testUser(confirmed: false)), + ); + expect((status as SignedIn).user.emailConfirmed, isFalse); + }); + + test('signedOut -> signedOut', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.signedIn, testSession()); + expect( + tracker.handle(AuthChangeEvent.signedOut, null), + const AuthStatus.signedOut(), + ); + }); + }); + + group('KTO-01 Kriterium 8: recovery guard (process only)', () { + test('passwordRecovery -> passwordRecovery', () { + final tracker = AuthStatusTracker(); + expect( + tracker.handle(AuthChangeEvent.passwordRecovery, testSession()), + isA(), + ); + }); + + test('guard survives tokenRefreshed and signedIn during recovery', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + expect( + tracker.handle(AuthChangeEvent.tokenRefreshed, testSession()), + isA(), + ); + expect( + tracker.handle(AuthChangeEvent.signedIn, testSession()), + isA(), + ); + expect(tracker.statusFor(testSession()), isA()); + }); + + test('userUpdated (password changed) ends the recovery', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + expect( + tracker.handle(AuthChangeEvent.userUpdated, testSession()), + isA(), + ); + }); + + test('signedOut (abort) ends the recovery', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + tracker.handle(AuthChangeEvent.signedOut, null); + expect(tracker.statusFor(testSession()), isA()); + }); + + test('a fresh tracker never derives recovery from a session (reload)', () { + expect(AuthStatusTracker().statusFor(testSession()), isA()); + }); + }); +} diff --git a/app/test/features/auth/data/auth_test_support.dart b/app/test/features/auth/data/auth_test_support.dart new file mode 100644 index 0000000..dc6c4f2 --- /dev/null +++ b/app/test/features/auth/data/auth_test_support.dart @@ -0,0 +1,24 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +User testUser({ + String id = 'user-1', + String? email = 'anna@example.com', + bool confirmed = true, + List? identities, +}) => User( + id: id, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-11-02T10:00:00Z', + email: email, + emailConfirmedAt: confirmed ? '2026-11-02T10:05:00Z' : null, + identities: identities, +); + +Session testSession({User? user}) => Session( + accessToken: 'access', + tokenType: 'bearer', + refreshToken: 'refresh', + user: user ?? testUser(), +); diff --git a/app/test/features/auth/data/common_passwords_test.dart b/app/test/features/auth/data/common_passwords_test.dart new file mode 100644 index 0000000..f57f6ff --- /dev/null +++ b/app/test/features/auth/data/common_passwords_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/data/asset_common_password_list.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('KTO-01 Kriterium 3: common password asset', () { + late List lines; + + setUpAll(() { + lines = File(AssetCommonPasswordList.assetPath) + .readAsStringSync() + .split('\n') + .where((l) => l.isNotEmpty) + .toList(); + }); + + test('has a bounded size', () { + expect(lines.length, inInclusiveRange(5000, 15000)); + expect( + File(AssetCommonPasswordList.assetPath).lengthSync(), + lessThan(200 * 1024), + ); + }); + + test('contains only normalized entries of at least 10 code points', () { + for (final line in lines) { + expect(line.runes.length, greaterThanOrEqualTo(10), reason: line); + expect(line, line.trim(), reason: line); + expect(line, line.toLowerCase(), reason: line); + expect(line.startsWith('#'), isFalse, reason: line); + } + }); + + test('has no duplicates', () { + expect(lines.toSet().length, lines.length); + }); + + test('is declared as a bundle asset and loads lazily into a set', () async { + final list = await const AssetCommonPasswordList().load(); + + expect(list.contains('passwort1234'), isTrue); + expect(list.contains('Hallo12345'), isTrue); + expect(list.contains(' PASSWORT1234 '), isTrue); + expect(list.contains('korrekt-pferd-batterie-heftklammer'), isFalse); + }); + + test('fails loudly if the asset is missing', () async { + await expectLater( + AssetCommonPasswordList(bundle: _EmptyBundle()).load(), + throwsA(isA()), + ); + }); + }); + + group('parseCommonPasswords', () { + test('normalizes lines and skips blanks and comments', () { + final list = parseCommonPasswords( + '# c\n\n ABCDEFGHIJ \r\nklmnopqrstu\n', + ); + + expect(list.contains('abcdefghij'), isTrue); + expect(list.contains('klmnopqrstu'), isTrue); + expect(list.contains('# c'), isFalse); + expect(list.contains(''), isFalse); + }); + }); +} + +class _EmptyBundle extends CachingAssetBundle { + _EmptyBundle(); + + @override + Future load(String key) => + Future.error(FlutterError('missing asset $key')); +} diff --git a/app/test/features/auth/data/supabase_auth_repository_test.dart b/app/test/features/auth/data/supabase_auth_repository_test.dart new file mode 100644 index 0000000..994a68e --- /dev/null +++ b/app/test/features/auth/data/supabase_auth_repository_test.dart @@ -0,0 +1,359 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/features/auth/data/supabase_auth_repository.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; + +import '../../../helpers/fakes.dart'; +import 'auth_test_support.dart'; + +const _input = RegistrationInput( + email: ' anna@example.com ', + password: ' geheimes-passwort ', + displayName: ' Anna ', + ageConfirmed: true, + termsAccepted: true, + termsVersion: 'test-1', + locale: 'de', +); + +void main() { + late MockGoTrueClient auth; + late StreamController events; + late Session? currentSession; + late SupabaseAuthRepository repository; + + setUpAll(() { + registerFallbackValue(OtpType.signup); + registerFallbackValue(UserAttributes()); + }); + + setUp(() { + auth = MockGoTrueClient(); + events = StreamController.broadcast(sync: true); + currentSession = null; + when(() => auth.currentSession).thenAnswer((_) => currentSession); + when(() => auth.onAuthStateChange).thenAnswer((_) => events.stream); + repository = SupabaseAuthRepository(auth); + }); + + tearDown(() async { + await repository.dispose(); + await events.close(); + }); + + group('KTO-01 Kriterium 1: signUp', () { + test('sends trimmed values and JSON-boolean metadata', () async { + Map? args; + when( + () => auth.signUp( + email: any(named: 'email'), + password: any(named: 'password'), + data: any(named: 'data'), + ), + ).thenAnswer((invocation) async { + args = invocation.namedArguments; + return AuthResponse(); + }); + + final result = await repository.signUp(_input); + + expect(result.isSuccess, isTrue); + expect(args![#email], 'anna@example.com'); + expect(args![#password], ' geheimes-passwort ', reason: 'untouched'); + final captured = [args![#email], args![#password], args![#data]]; + final data = captured[2] as Map; + expect(data, { + 'display_name': 'Anna', + 'age_confirmed': true, + 'terms_accepted': true, + 'terms_version': 'test-1', + 'locale': 'de', + }); + expect(data['age_confirmed'], isA()); + expect(data['terms_accepted'], isA()); + }); + + test( + 'KTO-01 Kriterium 4: 200 with empty identities is plain success', + () async { + when( + () => auth.signUp( + email: any(named: 'email'), + password: any(named: 'password'), + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => AuthResponse(user: testUser(identities: const [])), + ); + + final result = await repository.signUp(_input); + + expect(result.isSuccess, isTrue); + expect(result.errorOrNull, isNull); + }, + ); + + test( + 'KTO-01 Kriterium 10: trigger error (HTTP 500) is registrationRejected', + () async { + when( + () => auth.signUp( + email: any(named: 'email'), + password: any(named: 'password'), + data: any(named: 'data'), + ), + ).thenThrow( + AuthRetryableFetchException( + message: '{"code":"P0001","message":"display_name_invalid"}', + statusCode: '500', + ), + ); + + final result = await repository.signUp(_input); + + expect( + result.errorOrNull, + const AuthFailure.registrationRejected( + reason: RegistrationRejectionReason.displayName, + ), + ); + }, + ); + + test( + 'KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited', + () async { + when( + () => auth.signUp( + email: any(named: 'email'), + password: any(named: 'password'), + data: any(named: 'data'), + ), + ).thenThrow( + const AuthApiException( + 'email rate limit exceeded', + statusCode: '429', + code: 'over_email_send_rate_limit', + ), + ); + + expect( + (await repository.signUp(_input)).errorOrNull, + const AuthFailure.emailSendLimited(), + ); + }, + ); + }); + + group('other calls', () { + test( + 'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email', + () async { + when( + () => auth.resend( + type: any(named: 'type'), + email: any(named: 'email'), + ), + ).thenAnswer((_) async => ResendResponse()); + + final result = await repository.resendConfirmation( + ' anna@example.com ', + ); + + expect(result.isSuccess, isTrue); + verify( + () => auth.resend(type: OtpType.signup, email: 'anna@example.com'), + ).called(1); + }, + ); + + test('KTO-01 Kriterium 7: signIn maps invalid_credentials', () async { + when( + () => auth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), + ), + ).thenThrow( + const AuthApiException( + 'Invalid login credentials', + statusCode: '400', + code: 'invalid_credentials', + ), + ); + + final result = await repository.signIn('anna@example.com', 'x'); + + expect(result.errorOrNull, const AuthFailure.invalidCredentials()); + }); + + test('KTO-01 Kriterium 8: requestPasswordReset', () async { + when(() => auth.resetPasswordForEmail(any())).thenAnswer((_) async {}); + + final result = await repository.requestPasswordReset(' anna@example.com'); + + expect(result.isSuccess, isTrue); + verify(() => auth.resetPasswordForEmail('anna@example.com')).called(1); + }); + + test( + 'KTO-01 Kriterium 8: recovery link sends only token hash and type', + () async { + when( + () => auth.verifyOTP( + type: any(named: 'type'), + tokenHash: any(named: 'tokenHash'), + ), + ).thenAnswer((_) async => AuthResponse(session: testSession())); + + final result = await repository.verifyEmailLink( + 'hash', + EmailLinkType.recovery, + ); + + expect(result.isSuccess, isTrue); + verify(() => auth.verifyOTP(type: OtpType.recovery, tokenHash: 'hash')) + .called(1); + }, + ); + + test('KTO-01 Kriterium 1: confirmation link uses OtpType.email', () async { + when( + () => auth.verifyOTP( + type: any(named: 'type'), + tokenHash: any(named: 'tokenHash'), + ), + ).thenAnswer((_) async => AuthResponse(session: testSession())); + + await repository.verifyEmailLink('hash', EmailLinkType.confirmation); + + verify(() => auth.verifyOTP(type: OtpType.email, tokenHash: 'hash')) + .called(1); + }); + + test('KTO-01 Kriterium 8: used link -> linkInvalidOrExpired', () async { + when( + () => auth.verifyOTP( + type: any(named: 'type'), + tokenHash: any(named: 'tokenHash'), + ), + ).thenThrow( + const AuthApiException( + 'Email link is invalid or has expired', + statusCode: '403', + code: 'otp_expired', + ), + ); + + final result = await repository.verifyEmailLink( + 'hash', + EmailLinkType.recovery, + ); + + expect(result.errorOrNull, const AuthFailure.linkInvalidOrExpired()); + }); + + test('verify without a session is unexpected', () async { + when( + () => auth.verifyOTP( + type: any(named: 'type'), + tokenHash: any(named: 'tokenHash'), + ), + ).thenAnswer((_) async => AuthResponse()); + + final result = await repository.verifyEmailLink( + 'hash', + EmailLinkType.confirmation, + ); + + expect(result.errorOrNull, const AuthFailure.unexpected()); + }); + + test('KTO-01 Kriterium 8: setNewPassword maps same_password', () async { + when(() => auth.updateUser(any())).thenThrow( + const AuthApiException( + 'same', + statusCode: '422', + code: 'same_password', + ), + ); + + final result = await repository.setNewPassword('neues-passwort'); + + expect(result.errorOrNull, const AuthFailure.samePassword()); + }); + + test('setNewPassword sends the password attribute', () async { + when(() => auth.updateUser(any())) + .thenAnswer((_) async => UserResponse.fromJson(testUser().toJson())); + + final result = await repository.setNewPassword('neues-passwort'); + + expect(result.isSuccess, isTrue); + final attrs = + verify(() => auth.updateUser(captureAny())).captured.single + as UserAttributes; + expect(attrs.password, 'neues-passwort'); + }); + + test('signOut swallows errors', () async { + when(() => auth.signOut()).thenThrow(StateError('boom')); + + await expectLater(repository.signOut(), completes); + }); + }); + + group('KTO-01 Kriterium 5, 8: status', () { + test('currentStatus reflects the stored session at construction', () async { + currentSession = testSession(); + final restored = SupabaseAuthRepository(auth); + addTearDown(restored.dispose); + + expect(restored.currentStatus, isA()); + }); + + test('watchStatus emits the current value, then changes', () async { + final emitted = []; + final sub = repository.watchStatus().listen(emitted.add); + addTearDown(sub.cancel); + await Future.delayed(Duration.zero); + + events.add(AuthState(AuthChangeEvent.signedIn, testSession())); + events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession())); + events.add(AuthState(AuthChangeEvent.userUpdated, testSession())); + events.add(const AuthState(AuthChangeEvent.signedOut, null)); + await Future.delayed(Duration.zero); + + expect(emitted.map((s) => s.runtimeType.toString()), [ + 'SignedOut', + 'SignedIn', + 'PasswordRecovery', + 'SignedIn', + 'SignedOut', + ]); + }); + + test('identical statuses (token refresh) are not re-emitted', () async { + final emitted = []; + final sub = repository.watchStatus().listen(emitted.add); + addTearDown(sub.cancel); + + events.add(AuthState(AuthChangeEvent.signedIn, testSession())); + events.add(AuthState(AuthChangeEvent.tokenRefreshed, testSession())); + await Future.delayed(Duration.zero); + + expect(emitted, hasLength(2)); + }); + + test('recovery from a verified link is visible synchronously', () async { + events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession())); + + expect(repository.currentStatus, isA()); + }); + }); +} diff --git a/app/test/features/auth/domain/auth_domain_test.dart b/app/test/features/auth/domain/auth_domain_test.dart new file mode 100644 index 0000000..d7b118f --- /dev/null +++ b/app/test/features/auth/domain/auth_domain_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; + +void main() { + group('KTO-01 Kriterium 6: resendFailureFor (F12)', () { + const limited = AuthFailure.emailSendLimited(); + + test('check-email screen treats emailSendLimited as success', () { + expect(resendFailureFor(limited, ResendOrigin.checkEmail), isNull); + }); + + test('login screen shows the throttling hint', () { + expect( + resendFailureFor(limited, ResendOrigin.login), + const AuthFailure.rateLimited(), + ); + }); + + test('other failures pass through unchanged', () { + for (final origin in ResendOrigin.values) { + expect( + resendFailureFor(const AuthFailure.rateLimited(), origin), + const AuthFailure.rateLimited(), + ); + expect( + resendFailureFor(const AuthFailure.network(), origin), + const AuthFailure.network(), + ); + } + }); + }); + + group('KTO-01 Kriterium 4: openEmailInputFailureFor (F12)', () { + test('emailSendLimited counts as success', () { + expect( + openEmailInputFailureFor(const AuthFailure.emailSendLimited()), + isNull, + ); + }); + + test('rateLimited stays visible', () { + expect( + openEmailInputFailureFor(const AuthFailure.rateLimited()), + const AuthFailure.rateLimited(), + ); + }); + }); + + group('KTO-01 Kriterium 8: EmailLinkType.tryParse', () { + test('maps email and signup to confirmation, recovery to recovery', () { + expect(EmailLinkType.tryParse('email'), EmailLinkType.confirmation); + expect(EmailLinkType.tryParse('signup'), EmailLinkType.confirmation); + expect(EmailLinkType.tryParse('recovery'), EmailLinkType.recovery); + }); + + test('rejects missing and unknown types', () { + expect(EmailLinkType.tryParse(null), isNull); + expect(EmailLinkType.tryParse(''), isNull); + expect(EmailLinkType.tryParse('magiclink'), isNull); + expect(EmailLinkType.tryParse('invite'), isNull); + }); + }); +} diff --git a/app/test/features/auth/domain/validation/display_name_rule_test.dart b/app/test/features/auth/domain/validation/display_name_rule_test.dart new file mode 100644 index 0000000..c780e99 --- /dev/null +++ b/app/test/features/auth/domain/validation/display_name_rule_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; + +void main() { + group('KTO-01 Kriterium 10: DisplayNameRule', () { + test('accepts names of 1 to 30 code points', () { + expect(DisplayNameRule.validate('A'), isNull); + expect(DisplayNameRule.validate('a' * 30), isNull); + }); + + test('rejects empty and whitespace-only names', () { + expect(DisplayNameRule.validate(''), DisplayNameError.empty); + expect(DisplayNameRule.validate(' '), DisplayNameError.empty); + expect(DisplayNameRule.validate('\t \n'), DisplayNameError.empty); + }); + + test('rejects 31 code points', () { + expect(DisplayNameRule.validate('a' * 31), DisplayNameError.tooLong); + }); + + test('counts code points, not UTF-16 units (emoji)', () { + expect(DisplayNameRule.validate('😀' * 30), isNull); + expect(DisplayNameRule.validate('😀' * 31), DisplayNameError.tooLong); + expect(DisplayNameRule.length('😀' * 30), 30); + expect('😀' * 30, hasLength(60)); + }); + + test('counts every code point of a ZWJ emoji sequence', () { + // Family emoji: 3 emoji + 2 ZWJ = 5 code points. + expect(DisplayNameRule.length('👨‍👩‍👧'), 5); + expect(DisplayNameRule.validate('👨‍👩‍👧' * 6), isNull); + expect( + DisplayNameRule.validate('👨‍👩‍👧' * 6 + 'x'), + DisplayNameError.tooLong, + ); + }); + + test('trims NBSP, tab, BOM and ideographic space like the server', () { + expect(DisplayNameRule.normalize(' \tAnna '), 'Anna'); + expect(DisplayNameRule.validate('  '), DisplayNameError.empty); + expect(DisplayNameRule.validate(' ${'a' * 30}\t'), isNull); + }); + + test('counts only the trimmed name', () { + expect(DisplayNameRule.validate(' ${'a' * 30} '), isNull); + expect(DisplayNameRule.length(' ab '), 2); + }); + + test('rejects U+200B anywhere, even alone or at the edges', () { + const invalid = DisplayNameError.invalidCharacter; + expect(DisplayNameRule.validate('​'), invalid); + expect(DisplayNameRule.validate('Anna​'), invalid); + expect(DisplayNameRule.validate('​Anna'), invalid); + expect(DisplayNameRule.validate('An​na'), invalid); + expect(DisplayNameRule.validate(' ​ '), invalid); + }); + + test('does not treat ZWJ/ZWNJ as forbidden', () { + expect(DisplayNameRule.validate('a‍b‌'), isNull); + }); + }); +} diff --git a/app/test/features/auth/domain/validation/email_format_test.dart b/app/test/features/auth/domain/validation/email_format_test.dart new file mode 100644 index 0000000..d4b2c1b --- /dev/null +++ b/app/test/features/auth/domain/validation/email_format_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; + +void main() { + group('KTO-01 Kriterium 1: EmailFormat', () { + test('accepts common addresses (trimmed)', () { + expect(EmailFormat.validate('anna@example.com'), isNull); + expect(EmailFormat.validate(' anna.b+x@mail.example.de '), isNull); + }); + + test('rejects empty input', () { + expect(EmailFormat.validate(''), EmailError.empty); + expect(EmailFormat.validate(' '), EmailError.empty); + }); + + test('rejects malformed addresses', () { + for (final value in [ + 'anna', + 'anna@', + '@example.com', + 'anna@example', + 'anna@@example.com', + 'an na@example.com', + 'anna@example..com', + 'anna@.com', + ]) { + expect(EmailFormat.validate(value), EmailError.invalid, reason: value); + } + }); + + test('rejects addresses longer than 254 characters', () { + expect( + EmailFormat.validate('${'a' * 250}@example.com'), + EmailError.invalid, + ); + }); + }); +} diff --git a/app/test/features/auth/domain/validation/password_policy_test.dart b/app/test/features/auth/domain/validation/password_policy_test.dart new file mode 100644 index 0000000..2eba43f --- /dev/null +++ b/app/test/features/auth/domain/validation/password_policy_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; + +void main() { + const common = SetCommonPasswordList({'passwort1234', 'hallo12345'}); + + group('KTO-01 Kriterium 3: PasswordPolicy', () { + test('rejects 9 characters and reports how many are missing', () { + expect( + PasswordPolicy.validate('123456789'), + const PasswordError.tooShort(1), + ); + expect(PasswordPolicy.validate(''), const PasswordError.tooShort(10)); + }); + + test('accepts exactly 10 characters', () { + expect(PasswordPolicy.validate('abcdefghij'), isNull); + }); + + test('counts code points, not UTF-16 units', () { + expect( + PasswordPolicy.validate('😀' * 9), + const PasswordError.tooShort(1), + ); + expect(PasswordPolicy.validate('😀' * 10), isNull); + }); + + test('does not trim for the length check', () { + expect(PasswordPolicy.validate(' a'), isNull); + }); + + test('rejects common passwords ignoring case', () { + expect( + PasswordPolicy.validate('Passwort1234', common: common), + const PasswordError.common(), + ); + expect( + PasswordPolicy.validate('HALLO12345', common: common), + const PasswordError.common(), + ); + }); + + test('compares common passwords after trim', () { + expect( + PasswordPolicy.validate(' hallo12345 ', common: common), + const PasswordError.common(), + ); + }); + + test('too short wins over common', () { + expect( + PasswordPolicy.validate( + 'hallo', + common: const SetCommonPasswordList({'hallo'}), + ), + const PasswordError.tooShort(5), + ); + }); + + test('checks only the length while the list is not loaded', () { + expect(PasswordPolicy.validate('passwort1234'), isNull); + }); + + test('accepts an uncommon long password', () { + expect( + PasswordPolicy.validate('korrekt-pferd-batterie', common: common), + isNull, + ); + }); + }); +} diff --git a/app/test/features/auth/presentation/auth_redirect_test.dart b/app/test/features/auth/presentation/auth_redirect_test.dart new file mode 100644 index 0000000..671b194 --- /dev/null +++ b/app/test/features/auth/presentation/auth_redirect_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; + +const _user = AuthUser( + id: 'u1', + email: 'anna@example.com', + emailConfirmed: true, +); +const _signedOut = AuthStatus.signedOut(); +const _signedIn = AuthStatus.signedIn(_user); +const _recovery = AuthStatus.passwordRecovery(_user); + +String? _redirect( + AuthStatus status, + String location, { + List privatePaths = const [], +}) => authRedirectFor(status, Uri.parse(location), privatePaths: privatePaths); + +void main() { + group('KTO-01 Kriterium 8: passwordRecovery', () { + test('every route except /reset-password leads to /reset-password', () { + for (final path in [ + '/', + '/login', + '/register', + '/forgot-password', + '/auth/confirm?token_hash=x&type=recovery', + '/anything', + ]) { + expect( + _redirect(_recovery, path), + AppRoutes.resetPassword, + reason: path, + ); + } + }); + + test('/reset-password stays', () { + expect(_redirect(_recovery, '/reset-password'), isNull); + }); + }); + + group('KTO-01 Kriterium 5: signedIn', () { + test('guest-only pages lead to the start page', () { + for (final path in ['/login', '/register', '/forgot-password']) { + expect(_redirect(_signedIn, path), AppRoutes.home, reason: path); + } + }); + + test('login honours a safe from parameter', () { + expect( + _redirect(_signedIn, '/login?from=%2Fprivate%3Fa%3D1'), + '/private?a=1', + ); + }); + + test('unsafe from values fall back to the start page', () { + for (final from in [ + 'https://evil.example', + '//evil.example', + r'/\evil.example', + 'private', + '/login', + '/reset-password', + '/auth/confirm', + ]) { + expect( + _redirect(_signedIn, '/login?from=${Uri.encodeComponent(from)}'), + AppRoutes.home, + reason: from, + ); + } + }); + + test('public pages, check-email and the link page stay', () { + for (final path in ['/', '/register/check-email', '/auth/confirm?x=1']) { + expect(_redirect(_signedIn, path), isNull, reason: path); + } + }); + + test('/reset-password is only for passwordRecovery', () { + expect(_redirect(_signedIn, '/reset-password'), AppRoutes.home); + }); + }); + + group('Sicherheit Kriterium 2: signedOut', () { + test('public pages stay reachable', () { + for (final path in [ + '/', + '/login', + '/register', + '/register/check-email', + '/forgot-password', + '/auth/confirm?token_hash=x&type=email', + '/does-not-exist', + ]) { + expect(_redirect(_signedOut, path), isNull, reason: path); + } + }); + + test('/reset-password is only for passwordRecovery', () { + expect(_redirect(_signedOut, '/reset-password'), AppRoutes.login); + }); + + test('private routes lead to /login?from=', () { + final target = _redirect( + _signedOut, + '/collection/42?tab=games', + privatePaths: ['/collection'], + ); + + final uri = Uri.parse(target!); + expect(uri.path, AppRoutes.login); + expect(uri.queryParameters['from'], '/collection/42?tab=games'); + }); + + test('private prefix matches whole segments only', () { + expect( + _redirect(_signedOut, '/collections', privatePaths: ['/collection']), + isNull, + ); + expect( + _redirect(_signedOut, '/collection', privatePaths: ['/collection']), + isNotNull, + ); + }); + + test('without private paths nothing is private (404 stays a 404)', () { + expect(_redirect(_signedOut, '/collection'), isNull); + }); + }); + + group('safeFromPath', () { + test('rejects auth pages with a trailing slash', () { + expect(safeFromPath('/login/'), isNull); + expect(safeFromPath('/login//'), isNull); + expect(safeFromPath('/login/?x=1'), isNull); + expect(safeFromPath('/reset-password/'), isNull); + }); + + test('accepts internal paths with query', () { + expect(safeFromPath('/games/1?x=2'), '/games/1?x=2'); + }); + + test('rejects null, empty and absolute URLs', () { + expect(safeFromPath(null), isNull); + expect(safeFromPath(''), isNull); + expect(safeFromPath('javascript:alert(1)'), isNull); + }); + }); +} diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart new file mode 100644 index 0000000..eb46ecc --- /dev/null +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/routing/app_router.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; + +import '../../../helpers/fakes.dart'; +import '../../../helpers/pump_app.dart'; +import '../../../helpers/router_helpers.dart'; +import '../data/auth_test_support.dart'; + +void main() { + late SupabaseMocks mocks; + late StreamController events; + late Session? session; + late int signedOutCalls; + + setUp(() { + mocks = SupabaseMocks(); + events = StreamController.broadcast(sync: true); + session = null; + signedOutCalls = 0; + when(() => mocks.auth.currentSession).thenAnswer((_) => session); + when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream); + }); + + tearDown(() => events.close()); + + void emit(AuthChangeEvent event, {Session? withSession}) { + session = withSession; + events.add(AuthState(event, withSession)); + } + + Future start(WidgetTester tester) async { + final container = await pumpShuffleApp( + tester, + overrides: testOverrides( + supabase: mocks.client, + overrides: [ + ...authOverrides(), + privateRoutePathsProvider.overrideWithValue(const ['/private']), + signedOutHandlersProvider.overrideWithValue([ + (ref) => signedOutCalls++, + ]), + ], + ), + ); + return (container: container, router: container.read(appRouterProvider)); + } + + group('KTO-01 Kriterium 5: authStatusProvider', () { + testWidgets('maps events and starts with the stored session', ( + tester, + ) async { + session = testSession(); + final (:container, router: _) = await start(tester); + + expect(container.read(authStatusProvider).value, isA()); + + emit(AuthChangeEvent.signedOut); + await tester.pumpAndSettle(); + expect(container.read(authStatusProvider).value, isA()); + + emit(AuthChangeEvent.signedIn, withSession: testSession()); + await tester.pumpAndSettle(); + expect(container.read(authStatusProvider).value, isA()); + }); + }); + + group('Sicherheit Kriterium 2: router redirects', () { + testWidgets('private route while signed out -> /login?from=', ( + tester, + ) async { + final (container: _, :router) = await start(tester); + + router.go('/private?tab=1'); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + expect(currentUri(router).queryParameters['from'], '/private?tab=1'); + expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget); + }); + + testWidgets('signing in on /login returns to from', (tester) async { + final (container: _, :router) = await start(tester); + router.go('/private'); + await tester.pumpAndSettle(); + + emit(AuthChangeEvent.signedIn, withSession: testSession()); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/private'); + }); + + testWidgets('signed in: login and register lead to the start page', ( + tester, + ) async { + session = testSession(); + final (container: _, :router) = await start(tester); + + router.go('/login'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/'); + + router.go('/register'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/'); + }); + + testWidgets( + 'routes of spec 5.3 exist (01a: real screens, rest placeholders)', + (tester) async { + final (container: _, :router) = await start(tester); + + for (final (path, key) in [ + ('/login', 'auth_placeholder_login'), + ('/register', 'register_screen'), + ('/register/check-email', 'check_email_screen'), + ('/forgot-password', 'auth_placeholder_forgot_password'), + ('/auth/confirm?token_hash=x&type=email', 'email_link_screen'), + ]) { + router.go(path); + await tester.pumpAndSettle(); + expect(find.byKey(Key(key)), findsOneWidget, reason: path); + } + }, + ); + }); + + group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () { + testWidgets('recovery forces /reset-password until the password changed', ( + tester, + ) async { + final (container: _, :router) = await start(tester); + + emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + expect( + find.byKey(const Key('auth_placeholder_reset_password')), + findsOneWidget, + ); + + router.go('/'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + + emit(AuthChangeEvent.tokenRefreshed, withSession: testSession()); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + + emit(AuthChangeEvent.userUpdated, withSession: testSession()); + await tester.pumpAndSettle(); + expect(currentPath(router), '/'); + }); + + testWidgets('aborting the recovery (signOut) leaves /reset-password', ( + tester, + ) async { + final (container: _, :router) = await start(tester); + emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); + await tester.pumpAndSettle(); + + emit(AuthChangeEvent.signedOut); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + }); + + testWidgets('onSignedOut runs on signed-in -> signed-out only', ( + tester, + ) async { + await start(tester); + expect(signedOutCalls, 0, reason: 'initial signed-out state'); + + emit(AuthChangeEvent.signedIn, withSession: testSession()); + await tester.pumpAndSettle(); + expect(signedOutCalls, 0); + + emit(AuthChangeEvent.signedOut); + await tester.pumpAndSettle(); + expect(signedOutCalls, 1); + + emit(AuthChangeEvent.signedOut); + await tester.pumpAndSettle(); + expect(signedOutCalls, 1, reason: 'no transition'); + }); + + testWidgets('a failed refresh on a private route ends on /login', ( + tester, + ) async { + session = testSession(); + final (container: _, :router) = await start(tester); + router.go('/private'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/private'); + + emit(AuthChangeEvent.signedOut); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + expect(signedOutCalls, 1); + }); + }); + + group('KTO-01 Kriterium 1: registrationInputFactory', () { + testWidgets('fills terms_version from AppConfig and the app language', ( + tester, + ) async { + final (:container, router: _) = await start(tester); + + final input = container.read(registrationInputFactoryProvider)( + email: 'a@b.de', + password: 'pw', + displayName: 'Anna', + ageConfirmed: true, + termsAccepted: true, + ); + + expect( + input, + const RegistrationInput( + email: 'a@b.de', + password: 'pw', + displayName: 'Anna', + ageConfirmed: true, + termsAccepted: true, + termsVersion: 'test-1', + locale: 'en', + ), + ); + }); + }); +} + +typedef ProviderContainerAndRouter = ({ + ProviderContainer container, + GoRouter router, +}); diff --git a/app/test/features/auth/presentation/auth_ui_support.dart b/app/test/features/auth/presentation/auth_ui_support.dart new file mode 100644 index 0000000..91af0a6 --- /dev/null +++ b/app/test/features/auth/presentation/auth_ui_support.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_riverpod/misc.dart' show ProviderListenable; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/l10n/app_locale.dart'; +import 'package:shuffle/core/platform/platform_info.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_repository.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +import '../../../helpers/fakes.dart'; +import '../../../helpers/pump_app.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +class FakeRegistrationInput extends Fake implements RegistrationInput {} + +/// [AppLocale] with a fixed language (tests do not depend on the device). +class FixedAppLocale extends AppLocale { + FixedAppLocale(this.locale); + + final Locale locale; + + @override + Locale build() => locale; +} + +const commonTestPasswords = SetCommonPasswordList({'passwort1234'}); + +void registerAuthFallbacks() { + registerFallbackValue(FakeRegistrationInput()); + registerFallbackValue(EmailLinkType.confirmation); +} + +Result ok() => const Result.success(null); + +Result failed(AuthFailure value) => Result.failure(value); + +/// Overrides for auth UI tests: repository mock, fixed language, common +/// password list, URL opener and platform. +List authUiOverrides( + MockAuthRepository repository, { + Locale locale = const Locale('de'), + UrlOpener? urlOpener, + bool mobileWeb = false, + Future Function()? commonPasswords, + List extra = const [], +}) => testOverrides( + overrides: [ + authRepositoryProvider.overrideWithValue(repository), + appLocaleProvider.overrideWith(() => FixedAppLocale(locale)), + commonPasswordListProvider.overrideWith( + (ref) => commonPasswords?.call() ?? Future.value(commonTestPasswords), + ), + urlOpenerProvider.overrideWithValue(urlOpener ?? (_) async => true), + isMobileWebProvider.overrideWithValue(mobileWeb), + ...extra, + ], +); + +/// The text field inside an `AppTextField`/`AppPasswordField` with [key]. +Finder fieldOf(String key) => + find.descendant(of: find.byKey(Key(key)), matching: find.byType(TextField)); + +/// Keeps an auto-dispose provider alive during a controller test. +void keepAlive(ProviderContainer container, ProviderListenable p) { + container.listen(p, (_, _) {}); +} + +typedef AuthUiHandle = ({ProviderContainer container, GoRouter router}); + +/// Pumps the real auth routes (register, check-email, confirm; the others are +/// placeholders) at [location] on a phone-sized screen. +Future pumpAuthRoute( + WidgetTester tester, + MockAuthRepository repository, { + String location = '/register', + Object? extra, + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + UrlOpener? urlOpener, + bool mobileWeb = false, + bool settle = true, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + initialLocation: location, + initialExtra: extra, + routes: authRoutes, + ); + addTearDown(router.dispose); + final container = await pumpApp( + tester, + router: router, + locale: locale, + themeMode: themeMode, + textScale: textScale, + settle: settle, + overrides: authUiOverrides( + repository, + locale: locale, + urlOpener: urlOpener, + mobileWeb: mobileWeb, + ), + ); + return (container: container, router: router); +} + +/// All texts currently visible (for comparing two screens exactly). +List visibleTexts(WidgetTester tester) => [ + for (final text in tester.widgetList(find.byType(Text))) + if (text.data != null) text.data!, +]; diff --git a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart new file mode 100644 index 0000000..820ce0f --- /dev/null +++ b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + Object? extra = 'anna@example.com', + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) => pumpAuthRoute( + tester, + repository, + location: '/register/check-email', + extra: extra, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + ); + + Future tapResend(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('check_email_resend'))); + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1, 4: neutral message', () { + testWidgets('shows body, validity and the delay hint always', ( + tester, + ) async { + await open(tester); + + expect(find.text(_de.authCheckEmailTitle), findsOneWidget); + expect(find.text(_de.authCheckEmailBody), findsOneWidget); + expect(find.text(_de.authCheckEmailValidity('1 Stunde')), findsOneWidget); + expect(find.text(_de.authCheckEmailDelayHint), findsOneWidget); + expect(find.byKey(const Key('check_email_email')), findsNothing); + }); + + testWidgets('the text does not contain the address', (tester) async { + await open(tester); + expect(find.textContaining('anna@example.com'), findsNothing); + }); + }); + + group('KTO-01 Kriterium 6: resend', () { + testWidgets('sends to the known address and confirms', (tester) async { + await open(tester); + + await tapResend(tester); + + verify(() => repository.resendConfirmation('anna@example.com')).called(1); + expect(find.text(_de.authCheckEmailResent), findsOneWidget); + }); + + testWidgets('F12: emailSendLimited gives exactly the same feedback', ( + tester, + ) async { + await open(tester); + await tapResend(tester); + final success = visibleTexts(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + when( + () => repository.resendConfirmation(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + await open(tester); + await tapResend(tester); + final limited = visibleTexts(tester); + + expect(limited, success); + expect(limited, isNot(contains(_de.authErrorRateLimited))); + expect(limited, contains(_de.authCheckEmailResent)); + }); + + testWidgets('IP rate limit shows the rate-limit hint', (tester) async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); + await open(tester); + + await tapResend(tester); + + expect(find.text(_de.authErrorRateLimited), findsOneWidget); + expect(find.text(_de.authCheckEmailResent), findsNothing); + }); + + testWidgets('offline shows the offline hint', (tester) async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + + await tapResend(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + }); + + testWidgets('the button is locked while sending (no double tap)', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) => completer.future); + await open(tester); + + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('check_email_resend'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + verify(() => repository.resendConfirmation(any())).called(1); + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + }); + + group('KTO-01 Kriterium 6: address missing after a web reload', () { + testWidgets('asks for the address and validates it', (tester) async { + await open(tester, extra: null); + + expect(find.byKey(const Key('check_email_email')), findsOneWidget); + await tapResend(tester); + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + verifyNever(() => repository.resendConfirmation(any())); + + await tester.enterText( + fieldOf('check_email_email'), + ' berta@example.com ', + ); + await tester.pump(); + expect(find.text(_de.authErrorEmailInvalid), findsNothing); + await tapResend(tester); + + verify(() => repository.resendConfirmation('berta@example.com')) + .called(1); + expect(find.text(_de.authCheckEmailResent), findsOneWidget); + }); + + testWidgets('an extra of the wrong type counts as missing', (tester) async { + await open(tester, extra: 42); + expect(find.byKey(const Key('check_email_email')), findsOneWidget); + }); + }); + + testWidgets('back to sign in leads to /login', (tester) async { + final (:container, :router) = await open(tester); + + await tester.ensureVisible( + find.byKey(const Key('check_email_back_to_login')), + ); + await tester.tap(find.byKey(const Key('check_email_back_to_login'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authCheckEmailBody), findsOneWidget); + expect(find.text(_en.authCheckEmailValidity('1 hour')), findsOneWidget); + expect(find.text(_en.authCheckEmailDelayHint), findsOneWidget); + expect(find.text(_en.authCheckEmailResend), findsOneWidget); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + for (final withEmail in [true, false]) { + testWidgets('meets the guidelines ($mode, address known: $withEmail)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open( + tester, + themeMode: mode, + extra: withEmail ? 'anna@example.com' : null, + ); + await tapResend(tester); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + } + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, extra: null, textScale: 2, size: const Size(320, 640)); + await tapResend(tester); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/features/auth/presentation/check_email/resend_controller_test.dart b/app/test/features/auth/presentation/check_email/resend_controller_test.dart new file mode 100644 index 0000000..d9d0325 --- /dev/null +++ b/app/test/features/auth/presentation/check_email/resend_controller_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; + +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + keepAlive(container, resendControllerProvider); + }); + + ResendController notifier() => + container.read(resendControllerProvider.notifier); + ResendState state() => container.read(resendControllerProvider); + + void stub(Result result) => + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => result); + + group('KTO-01 Kriterium 6: resend', () { + test('sends to the trimmed address and reports success', () async { + stub(ok()); + + await notifier().resend(' anna@example.com ', origin: ResendOrigin.login); + + verify(() => repository.resendConfirmation('anna@example.com')).called(1); + expect(state().sent, isTrue); + expect(state().failure, isNull); + }); + + test('an invalid address is not sent', () async { + await notifier().resend('anna', origin: ResendOrigin.checkEmail); + + verifyNever(() => repository.resendConfirmation(any())); + expect(state().emailError, EmailError.invalid); + notifier().clearEmailError(); + expect(state().emailError, isNull); + }); + + test('the button is locked while a request runs', () async { + final completer = Completer>(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) => completer.future); + + final first = notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + await Future.delayed(Duration.zero); + expect(state().isSending, isTrue); + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + completer.complete(ok()); + await first; + + verify(() => repository.resendConfirmation(any())).called(1); + expect(state().sent, isTrue); + }); + }); + + group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () { + test('from the check-email screen counts as sent', () async { + stub(failed(const AuthFailure.emailSendLimited())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().sent, isTrue); + expect(state().failure, isNull); + }); + + test('from the login screen becomes the rate-limit failure', () async { + stub(failed(const AuthFailure.emailSendLimited())); + + await notifier().resend('anna@example.com', origin: ResendOrigin.login); + + expect(state().sent, isFalse); + expect(state().failure, const AuthFailure.rateLimited()); + }); + + test('over_request_rate_limit is shown from both origins', () async { + stub(failed(const AuthFailure.rateLimited())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().failure, const AuthFailure.rateLimited()); + }); + + test('Zustände Kriterium 2: network failure is reported', () async { + stub(failed(const AuthFailure.network())); + + await notifier().resend( + 'anna@example.com', + origin: ResendOrigin.checkEmail, + ); + + expect(state().failure, const AuthFailure.network()); + expect(state().isSending, isFalse); + }); + }); +} diff --git a/app/test/features/auth/presentation/email_link/email_link_controller_test.dart b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart new file mode 100644 index 0000000..0a03c95 --- /dev/null +++ b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_request.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/features/auth/presentation/email_link/email_link_controller.dart'; + +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + }); + + EmailLinkController start(String? token, String? type) { + final provider = emailLinkControllerProvider(token, type); + keepAlive(container, provider); + return container.read(provider.notifier); + } + + EmailLinkState stateOf(String? token, String? type) => + container.read(emailLinkControllerProvider(token, type)); + + group('KTO-01 Kriterium 8: parameters', () { + test('email and signup mean confirmation, recovery stays recovery', () { + expect( + EmailLinkRequest.tryParse('t', 'email')?.type, + EmailLinkType.confirmation, + ); + expect( + EmailLinkRequest.tryParse('t', 'signup')?.type, + EmailLinkType.confirmation, + ); + expect( + EmailLinkRequest.tryParse('t', 'recovery')?.type, + EmailLinkType.recovery, + ); + }); + + test('missing token or unknown type give no request', () { + expect(EmailLinkRequest.tryParse(null, 'email'), isNull); + expect(EmailLinkRequest.tryParse(' ', 'email'), isNull); + expect(EmailLinkRequest.tryParse('t', null), isNull); + expect(EmailLinkRequest.tryParse('t', 'magiclink'), isNull); + }); + + test( + 'invalid parameters start in the invalid state and never verify', + () async { + final controller = start(null, 'email'); + expect(stateOf(null, 'email').phase, EmailLinkPhase.invalid); + + await controller.confirm(); + + verifyNever(() => repository.verifyEmailLink(any(), any())); + }, + ); + }); + + group('KTO-01 Kriterium 1, 8: redeeming', () { + test('creating the controller does not redeem the link (E3)', () { + start('hash', 'email'); + + verifyNever(() => repository.verifyEmailLink(any(), any())); + expect(stateOf('hash', 'email').phase, EmailLinkPhase.ready); + }); + + test('confirm redeems once with token and type', () async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + final controller = start('hash', 'signup'); + + await controller.confirm(); + await controller.confirm(); + + verify( + () => repository.verifyEmailLink('hash', EmailLinkType.confirmation), + ).called(1); + expect(stateOf('hash', 'signup').phase, EmailLinkPhase.success); + }); + + test('an expired or used link ends in the invalid state', () async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final controller = start('hash', 'email'); + + await controller.confirm(); + + expect(stateOf('hash', 'email').phase, EmailLinkPhase.invalid); + }); + + test( + 'Zustände Kriterium 2: a network failure allows another try', + () async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final controller = start('hash', 'email'); + + await controller.confirm(); + + final state = stateOf('hash', 'email'); + expect(state.phase, EmailLinkPhase.ready); + expect(state.failure, const AuthFailure.network()); + + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + await controller.confirm(); + expect(stateOf('hash', 'email').phase, EmailLinkPhase.success); + expect(stateOf('hash', 'email').failure, isNull); + }, + ); + }); +} diff --git a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart new file mode 100644 index 0000000..7f4e627 --- /dev/null +++ b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/email_link_type.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + String location = '/auth/confirm?token_hash=abc123&type=email', + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + bool mobileWeb = false, + }) => pumpAuthRoute( + tester, + repository, + location: location, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + mobileWeb: mobileWeb, + ); + + Future tapSubmit(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('email_link_submit'))); + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1, 8: nothing happens before the click (E3)', () { + testWidgets('opening the page does not redeem the link', (tester) async { + await open(tester); + + // Title and button share the text "E-Mail-Adresse bestätigen". + expect(find.text(_de.authConfirmTitle), findsNWidgets(2)); + expect(find.text(_de.authConfirmBody), findsOneWidget); + verifyNever(() => repository.verifyEmailLink(any(), any())); + }); + + testWidgets('type=signup is accepted as confirmation', (tester) async { + await open( + tester, + location: '/auth/confirm?token_hash=abc123&type=signup', + ); + + await tapSubmit(tester); + + verify( + () => repository.verifyEmailLink('abc123', EmailLinkType.confirmation), + ).called(1); + }); + }); + + group('KTO-01 Kriterium 1: confirmation', () { + testWidgets('the click redeems the link and shows the success', ( + tester, + ) async { + await open(tester); + + await tapSubmit(tester); + + verify( + () => repository.verifyEmailLink('abc123', EmailLinkType.confirmation), + ).called(1); + expect(find.text(_de.authConfirmSuccess), findsOneWidget); + expect(find.text(_de.authConfirmSuccessMobileWeb), findsNothing); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + }); + + testWidgets('mobile web adds the hint about the app', (tester) async { + await open(tester, mobileWeb: true); + + await tapSubmit(tester); + + expect( + find.text( + '${_de.authConfirmSuccess} ${_de.authConfirmSuccessMobileWeb}', + ), + findsOneWidget, + ); + }); + + testWidgets('continue leads to the start page', (tester) async { + final (:container, :router) = await open(tester); + await tapSubmit(tester); + + await tester.tap(find.byKey(const Key('email_link_continue'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/'); + }); + + testWidgets('while redeeming the button is locked', (tester) async { + final completer = Completer>(); + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) => completer.future); + await open(tester); + + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + verify(() => repository.verifyEmailLink(any(), any())).called(1); + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + }); + + group('KTO-01 Kriterium 8: invalid links', () { + testWidgets('an expired or used link shows the error and a way out', ( + tester, + ) async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final (:container, :router) = await open(tester); + + await tapSubmit(tester); + + expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + + await tester.tap(find.text(_de.authActionRequestNewLink)); + await tester.pumpAndSettle(); + expect(currentPath(router), '/login'); + }); + + for (final location in [ + '/auth/confirm', + '/auth/confirm?type=email', + '/auth/confirm?token_hash=abc', + '/auth/confirm?token_hash=abc&type=magiclink', + ]) { + testWidgets('missing or unknown parameters: $location', (tester) async { + await open(tester, location: location); + + expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + verifyNever(() => repository.verifyEmailLink(any(), any())); + }); + } + + testWidgets('Zustände Kriterium 2: offline allows another try', ( + tester, + ) async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + + await tapSubmit(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsOneWidget); + + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + await tapSubmit(tester); + expect(find.text(_de.authConfirmSuccess), findsOneWidget); + }); + }); + + testWidgets('recovery links show their own texts and redeem as recovery', ( + tester, + ) async { + await open( + tester, + location: '/auth/confirm?token_hash=abc123&type=recovery', + ); + + expect(find.text(_de.authRecoveryTitle), findsOneWidget); + expect(find.text(_de.authRecoverySubmit), findsOneWidget); + await tapSubmit(tester); + verify(() => repository.verifyEmailLink('abc123', EmailLinkType.recovery)) + .called(1); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authConfirmTitle), findsNWidgets(2)); + expect(find.text(_en.authConfirmBody), findsOneWidget); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, themeMode: mode); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + } + + testWidgets('the error state meets the guidelines', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, location: '/auth/confirm?token_hash=x&type=bad'); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, textScale: 2, size: const Size(320, 640)); + await tapSubmit(tester); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/features/auth/presentation/register/register_controller_test.dart b/app/test/features/auth/presentation/register/register_controller_test.dart new file mode 100644 index 0000000..ddc32e0 --- /dev/null +++ b/app/test/features/auth/presentation/register/register_controller_test.dart @@ -0,0 +1,433 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; +import 'package:shuffle/features/auth/presentation/register/register_state.dart'; + +import '../../../../helpers/fakes.dart'; +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late List opened; + late bool openResult; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + opened = []; + openResult = true; + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + }); + + ProviderContainer makeContainer({ + Locale locale = const Locale('de'), + Future Function()? commonPasswords, + }) { + final container = ProviderContainer.test( + overrides: authUiOverrides( + repository, + locale: locale, + urlOpener: (uri) async { + opened.add(uri); + return openResult; + }, + commonPasswords: commonPasswords, + ), + ); + keepAlive(container, registerControllerProvider); + return container; + } + + RegisterController notifier(ProviderContainer c) => + c.read(registerControllerProvider.notifier); + + RegisterState stateOf(ProviderContainer c) => + c.read(registerControllerProvider); + + void fillValid( + RegisterController controller, { + String email = ' anna@example.com ', + String password = 'geheim-und-lang', + String name = ' Anna ', + }) { + controller + ..emailChanged(email) + ..passwordChanged(password) + ..displayNameChanged(name) + ..ageConfirmedChanged(value: true) + ..termsAcceptedChanged(value: true); + } + + group('KTO-01 Kriterium 1: submit', () { + test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async { + final container = makeContainer(locale: const Locale('en')); + fillValid(notifier(container)); + + final result = await notifier(container).submit(); + + expect(result, isTrue); + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.email, 'anna@example.com'); + expect(input.password, 'geheim-und-lang'); + expect(input.displayName, 'Anna'); + expect(input.ageConfirmed, isTrue); + expect(input.termsAccepted, isTrue); + expect(input.termsVersion, testAppConfig().termsVersion); + expect(input.locale, 'en'); + expect(stateOf(container).isSubmitting, isFalse); + }); + + test('KTO-01 Kriterium 11 (Client): mail language follows the active UI language', () async { + final container = makeContainer(); + fillValid(notifier(container)); + + await notifier(container).submit(); + + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.locale, 'de'); + }); + + test('ignores a second submit while one is running', () async { + final completer = Completer>(); + when(() => repository.signUp(any())).thenAnswer((_) => completer.future); + final container = makeContainer(); + fillValid(notifier(container)); + + final first = notifier(container).submit(); + await Future.delayed(Duration.zero); + expect(stateOf(container).isSubmitting, isTrue); + expect(await notifier(container).submit(), isFalse); + completer.complete(ok()); + expect(await first, isTrue); + verify(() => repository.signUp(any())).called(1); + }); + }); + + test( + 'two triggers while the password list is still loading send only once', + () async { + final listLoaded = Completer(); + final container = makeContainer(commonPasswords: () => listLoaded.future); + fillValid(notifier(container)); + + final first = notifier(container).submit(); + final second = notifier(container).submit(); + expect(stateOf(container).isSubmitting, isTrue); + expect(await second, isFalse); + + listLoaded.complete(commonTestPasswords); + expect(await first, isTrue); + verify(() => repository.signUp(any())).called(1); + expect(stateOf(container).isSubmitting, isFalse); + }, + ); + + test('an invalid form leaves the submitting state again', () async { + final container = makeContainer(); + expect(await notifier(container).submit(), isFalse); + expect(stateOf(container).isSubmitting, isFalse); + }); + + group('KTO-01 Kriterium 2: minimum age', () { + test( + 'without the age confirmation nothing is sent and the error shows', + () async { + final container = makeContainer(); + fillValid(notifier(container)); + notifier(container).ageConfirmedChanged(value: false); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).shownAgeError, isTrue); + expect(stateOf(container).focusField, RegisterField.ageConfirmation); + }, + ); + + test('QUE-04 Kriterium 1: without consent nothing is sent', () async { + final container = makeContainer(); + fillValid(notifier(container)); + notifier(container).termsAcceptedChanged(value: false); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).shownTermsError, isTrue); + }); + + test('the error disappears when the box is ticked', () async { + final container = makeContainer(); + await notifier(container).submit(); + expect(stateOf(container).shownAgeError, isTrue); + + notifier(container).ageConfirmedChanged(value: true); + + expect(stateOf(container).shownAgeError, isFalse); + }); + }); + + group('KTO-01 Kriterium 3: password', () { + test('is checked while typing: too short names the missing count', () { + final container = makeContainer(); + + notifier(container).passwordChanged('123456789'); + + expect( + stateOf(container).shownPasswordError, + const PasswordError.tooShort(1), + ); + }); + + test('10 characters are enough', () { + final container = makeContainer(); + notifier(container).passwordChanged('1234567890'); + expect(stateOf(container).shownPasswordError, isNull); + }); + + test('a common password is rejected once the list is loaded', () async { + final container = makeContainer(); + + notifier(container).passwordChanged(' PassWort1234 '); + await notifier(container).loadCommonPasswords(); + + expect(stateOf(container).shownPasswordError, isA()); + expect(await notifier(container).submit(), isFalse); + verifyNever(() => repository.signUp(any())); + }); + + test('an empty password is shown as required after blur', () { + final container = makeContainer(); + notifier(container).fieldBlurred(RegisterField.password); + expect(stateOf(container).shownPasswordRequired, isTrue); + }); + + test( + 'weak_password from the server is mapped to the password field', + () async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => failed( + const AuthFailure.weakPassword([WeakPasswordReason.length]), + ), + ); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + + expect(stateOf(container).serverPasswordReasons, [ + WeakPasswordReason.length, + ]); + expect(stateOf(container).bannerFailure, isNull); + expect(stateOf(container).focusField, RegisterField.password); + + notifier(container).passwordChanged('anderes-passwort'); + expect(stateOf(container).serverPasswordReasons, isNull); + }, + ); + + test('Zustände Kriterium 3: if the list cannot be loaded, only the length rule runs', () async { + final container = makeContainer( + commonPasswords: () => Future.error(StateError('asset missing')), + ); + fillValid(notifier(container), password: 'passwort1234'); + + expect(await notifier(container).submit(), isTrue); + }); + }); + + group('KTO-01 Kriterium 4: no hint whether the account exists', () { + test('emailSendLimited counts as success (F12)', () async { + when( + () => repository.signUp(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + + test('plain success leads to the same result', () async { + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + + test('over_request_rate_limit stays a visible rate-limit banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + expect(stateOf(container).bannerFailure, const AuthFailure.rateLimited()); + }); + }); + + group('KTO-01 Kriterium 10: display name', () { + Future errorFor(String name) async { + final container = makeContainer(); + notifier(container) + ..displayNameChanged(name) + ..fieldBlurred(RegisterField.displayName); + return stateOf(container).shownDisplayNameError; + } + + test('empty and whitespace only are rejected', () async { + expect(await errorFor(''), DisplayNameError.empty); + expect(await errorFor(' '), DisplayNameError.empty); + }); + + test('30 code points are valid, 31 are too long', () async { + expect(await errorFor('a' * 30), isNull); + expect(await errorFor('a' * 31), DisplayNameError.tooLong); + }); + + test('counts code points, not UTF-16 units', () async { + expect(await errorFor('😀' * 30), isNull); + expect(await errorFor('😀' * 31), DisplayNameError.tooLong); + }); + + test('the counter uses the trimmed code point length', () { + final container = makeContainer(); + notifier(container).displayNameChanged(' 😀😀 '); + expect(stateOf(container).displayNameLength, 2); + }); + + test('U+200B anywhere is invalid', () async { + expect(await errorFor('A​B'), DisplayNameError.invalidCharacter); + expect(await errorFor('​'), DisplayNameError.invalidCharacter); + }); + + test('an invalid name blocks the submit', () async { + final container = makeContainer(); + fillValid(notifier(container), name: ' '); + + expect(await notifier(container).submit(), isFalse); + + verifyNever(() => repository.signUp(any())); + expect(stateOf(container).focusField, RegisterField.displayName); + }); + }); + + group('KTO-01 Kriterium 1: email', () { + test( + 'an invalid address is shown after blur and blocks the submit', + () async { + final container = makeContainer(); + notifier(container) + ..emailChanged('anna') + ..fieldBlurred(RegisterField.email); + + expect(stateOf(container).shownEmailError, EmailError.invalid); + expect(await notifier(container).submit(), isFalse); + }, + ); + + test('invalid email from the server goes to the email field', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.invalidEmail())); + final container = makeContainer(); + fillValid(notifier(container)); + + await notifier(container).submit(); + + expect(stateOf(container).serverEmailInvalid, isTrue); + expect(stateOf(container).focusField, RegisterField.email); + }); + }); + + group('Zustände Kriterium 2, 3: failures keep the input', () { + for (final failure in [ + const AuthFailure.network(), + const AuthFailure.unexpected(), + const AuthFailure.registrationRejected(), + ]) { + test('${failure.runtimeType} becomes a banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(failure)); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isFalse); + + final state = stateOf(container); + expect(state.bannerFailure, failure); + expect(state.isSubmitting, isFalse); + expect(state.password, 'geheim-und-lang'); + expect(state.displayName, ' Anna '); + expect(state.ageConfirmed, isTrue); + }); + } + + test('a new attempt clears the banner', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final container = makeContainer(); + fillValid(notifier(container)); + await notifier(container).submit(); + + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + }); + + group('QUE-04 Kriterium 1: legal texts', () { + test('open the URLs from the app config', () async { + final container = makeContainer(); + + expect( + await notifier(container).openLegalDocument(LegalDocument.privacy), + isTrue, + ); + await notifier(container).openLegalDocument(LegalDocument.terms); + + expect(opened, [ + Uri.parse(testAppConfig().legalPrivacyUrl), + Uri.parse(testAppConfig().legalTermsUrl), + ]); + }); + + test('report a failure to open', () async { + openResult = false; + final container = makeContainer(); + expect( + await notifier(container).openLegalDocument(LegalDocument.terms), + isFalse, + ); + }); + }); + + test('factory provider reads terms version and locale', () { + final container = makeContainer(); + final input = container.read(registrationInputFactoryProvider)( + email: 'a@b.de', + password: 'x', + displayName: 'n', + ageConfirmed: true, + termsAccepted: true, + ); + expect(input.termsVersion, 'test-1'); + expect(input.locale, 'de'); + }); +} diff --git a/app/test/features/auth/presentation/register/register_screen_test.dart b/app/test/features/auth/presentation/register/register_screen_test.dart new file mode 100644 index 0000000..95026c1 --- /dev/null +++ b/app/test/features/auth/presentation/register/register_screen_test.dart @@ -0,0 +1,508 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/registration_input.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/fakes.dart'; +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + UrlOpener? urlOpener, + }) => pumpAuthRoute( + tester, + repository, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + urlOpener: urlOpener, + ); + + Future type(WidgetTester tester, String key, String text) async { + await tester.ensureVisible(fieldOf(key)); + await tester.enterText(fieldOf(key), text); + await tester.pump(); + } + + Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pump(); + } + + Future fillValid(WidgetTester tester) async { + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + await type(tester, 'register_display_name', 'Anna'); + await tapKey(tester, 'register_age_checkbox'); + await tapKey(tester, 'register_terms_checkbox'); + } + + Future submit(WidgetTester tester) async { + await tapKey(tester, 'register_submit'); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 1: valid registration', () { + testWidgets('sends the data and shows the check-email page', ( + tester, + ) async { + final (:container, :router) = await open(tester); + + await fillValid(tester); + await submit(tester); + + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.email, 'anna@example.com'); + expect(input.displayName, 'Anna'); + expect(input.locale, 'de'); + expect(input.termsVersion, testAppConfig().termsVersion); + expect(currentPath(router), '/register/check-email'); + expect(find.byKey(const Key('check_email_screen')), findsOneWidget); + expect(find.text(_de.authCheckEmailBody), findsOneWidget); + }); + + testWidgets('the enter key in the last field submits', (tester) async { + final (:container, :router) = await open(tester); + await fillValid(tester); + + await tester.showKeyboard(fieldOf('register_display_name')); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + verify(() => repository.signUp(any())).called(1); + expect(currentPath(router), '/register/check-email'); + }); + + testWidgets('the fields carry autofill hints and keyboard types', ( + tester, + ) async { + await open(tester); + + final email = tester.widget(fieldOf('register_email')); + expect(email.autofillHints, contains(AutofillHints.email)); + expect(email.keyboardType, TextInputType.emailAddress); + expect(email.textInputAction, TextInputAction.next); + final password = tester.widget(fieldOf('register_password')); + expect(password.autofillHints, contains(AutofillHints.newPassword)); + expect(password.obscureText, isTrue); + final name = tester.widget(fieldOf('register_display_name')); + expect(name.textInputAction, TextInputAction.done); + }); + }); + + group('KTO-01 Kriterium 2: minimum age', () { + testWidgets( + 'without the confirmation the reason shows and nothing is sent', + (tester) async { + await open(tester); + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + await type(tester, 'register_display_name', 'Anna'); + await tapKey(tester, 'register_terms_checkbox'); + + await submit(tester); + + expect(find.text(_de.authAgeNotConfirmed), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }, + ); + + testWidgets('QUE-04 Kriterium 1: without consent the error shows', ( + tester, + ) async { + await open(tester); + await fillValid(tester); + await tapKey(tester, 'register_terms_checkbox'); + + await submit(tester); + + expect(find.text(_de.authTermsNotAccepted), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }); + }); + + group('KTO-01 Kriterium 3: password at the field', () { + testWidgets('shows the missing characters while typing', (tester) async { + await open(tester); + + await type(tester, 'register_password', 'abc'); + + expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); + await type(tester, 'register_password', 'abcdefghi'); + expect(find.text(_de.authPasswordTooShort(1)), findsOneWidget); + await type(tester, 'register_password', 'abcdefghij'); + expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing); + }); + + testWidgets( + 'the length hint is announced when the field is left, not while typing', + (tester) async { + await open(tester); + + bool live() => tester + .widget( + find.descendant( + of: find.byKey(const Key('register_password')), + matching: find.byType(AppFieldError), + ), + ) + .live; + + await type(tester, 'register_password', 'abc'); + expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); + expect(live(), isFalse); + await type(tester, 'register_password', 'abcd'); + expect(live(), isFalse); + + await tapKey(tester, 'register_display_name'); + await tester.pump(); + expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget); + expect(live(), isTrue); + }, + ); + + testWidgets('names a common password', (tester) async { + await open(tester); + + await type(tester, 'register_password', 'Passwort1234'); + await tester.pumpAndSettle(); + + expect(find.text(_de.authPasswordCommon), findsOneWidget); + }); + + testWidgets('shows the rule as hint before any error', (tester) async { + await open(tester); + expect(find.text(_de.authPasswordRule), findsOneWidget); + }); + + testWidgets('a weak_password answer shows at the field', (tester) async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => + failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), + ); + await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorWeakPasswordLength), findsOneWidget); + expect(find.byKey(const Key('register_banner')), findsNothing); + }); + }); + + group('KTO-01 Kriterium 10: display name at the field', () { + testWidgets('empty name shows the error on submit', (tester) async { + await open(tester); + await type(tester, 'register_email', 'anna@example.com'); + await type(tester, 'register_password', 'geheim-und-lang'); + + await submit(tester); + + expect(find.text(_de.authDisplayNameEmpty), findsOneWidget); + verifyNever(() => repository.signUp(any())); + }); + + testWidgets('31 characters are too long, the counter follows', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_display_name', 'a' * 31); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text(_de.authDisplayNameTooLong(30)), findsOneWidget); + expect(find.text('31/30'), findsOneWidget); + }); + + testWidgets('the counter counts trimmed code points', (tester) async { + await open(tester); + + await type(tester, 'register_display_name', ' 😀😀 '); + + expect(find.text('2/30'), findsOneWidget); + }); + + testWidgets('U+200B shows the same message as an invalid name', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_display_name', 'A​B'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text(_de.authDisplayNameInvalid), findsOneWidget); + }); + }); + + group('KTO-01 Kriterium 1: email at the field', () { + testWidgets('an invalid address shows after leaving the field', ( + tester, + ) async { + await open(tester); + + await type(tester, 'register_email', 'anna'); + await tester.tap(fieldOf('register_password')); + await tester.pumpAndSettle(); + + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + }); + + testWidgets('after a failed submit the first invalid field has focus', ( + tester, + ) async { + await open(tester); + + await submit(tester); + + final email = tester.widget(fieldOf('register_email')); + expect(email.focusNode?.hasFocus, isTrue); + }); + }); + + group('KTO-01 Kriterium 4 (F12): same screen for every answer', () { + Future> textsAfterSubmit( + WidgetTester tester, + Result answer, + ) async { + when(() => repository.signUp(any())).thenAnswer((_) async => answer); + final (:container, :router) = await open(tester); + await fillValid(tester); + await submit(tester); + expect(currentPath(router), '/register/check-email'); + final texts = visibleTexts(tester); + // Unmount so that provider disposal timers run before the next pump. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + return texts; + } + + testWidgets('success and emailSendLimited show identical texts', ( + tester, + ) async { + final success = await textsAfterSubmit(tester, ok()); + final limited = await textsAfterSubmit( + tester, + failed(const AuthFailure.emailSendLimited()), + ); + + expect(limited, success); + expect(limited, isNot(contains(_de.authErrorRateLimited))); + expect(limited, contains(_de.authCheckEmailDelayHint)); + }); + }); + + group('Zustände Kriterium 2, 3: loading, errors, offline', () { + testWidgets('while sending the button and fields are locked', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.signUp(any())).thenAnswer((_) => completer.future); + await open(tester); + await fillValid(tester); + + await tapKey(tester, 'register_submit'); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(fieldOf('register_email')).enabled, + false, + ); + final button = tester.widget( + find.descendant( + of: find.byKey(const Key('register_submit')), + matching: find.byType(FilledButton), + ), + ); + expect(button.onPressed, isNull); + + completer.complete(ok()); + await tester.pumpAndSettle(); + }); + + testWidgets('offline: shows the hint, keeps the input, can retry', ( + tester, + ) async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + final (:container, :router) = await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect(currentPath(router), '/register'); + expect( + tester.widget(fieldOf('register_email')).controller?.text, + 'anna@example.com', + ); + expect( + tester.widget(fieldOf('register_password')).controller?.text, + 'geheim-und-lang', + ); + + when(() => repository.signUp(any())).thenAnswer((_) async => ok()); + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + expect(currentPath(router), '/register/check-email'); + }); + + testWidgets('a rejected registration shows the general message', ( + tester, + ) async { + when(() => repository.signUp(any())).thenAnswer( + (_) async => failed(const AuthFailure.registrationRejected()), + ); + await open(tester); + await fillValid(tester); + + await submit(tester); + + expect(find.text(_de.authErrorRegistrationRejected), findsOneWidget); + }); + }); + + group('QUE-04 Kriterium 1: legal links', () { + testWidgets('privacy and terms open the configured URLs', (tester) async { + final opened = []; + await open( + tester, + urlOpener: (uri) async { + opened.add(uri); + return true; + }, + ); + + await tester.ensureVisible(find.text(_de.authCheckboxTermsPrivacyLink)); + await tester.tap(find.text(_de.authCheckboxTermsPrivacyLink)); + await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); + await tester.pump(); + + expect(opened, [ + Uri.parse(testAppConfig().legalPrivacyUrl), + Uri.parse(testAppConfig().legalTermsUrl), + ]); + // Opening a link does not tick the box. + final handle = tester.ensureSemantics(); + expect( + tester.getSemantics(find.byKey(const Key('register_terms_checkbox'))), + isNot(matchesSemantics(isChecked: true)), + ); + handle.dispose(); + }); + + testWidgets('a failing launch shows a hint', (tester) async { + await open(tester, urlOpener: (_) async => false); + + await tester.ensureVisible(find.text(_de.authCheckboxTermsTermsLink)); + await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); + await tester.pump(); + + expect(find.text(_de.authErrorUnexpected), findsOneWidget); + }); + }); + + group('QUE-05 Kriterium 2: de and en', () { + testWidgets( + 'KTO-01 Kriterium 11 (Client): english texts and english mail language', + (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authRegisterTitle), findsWidgets); + expect(find.text(_en.authFieldDisplayNameLabel), findsOneWidget); + expect(find.text(_en.authCheckboxAgeLabel), findsOneWidget); + expect(find.text(_de.authCheckboxAgeLabel), findsNothing); + + await fillValid(tester); + await submit(tester); + final input = + verify(() => repository.signUp(captureAny())).captured.single + as RegistrationInput; + expect(input.locale, 'en'); + expect(find.text(_en.authCheckEmailTitle), findsWidgets); + }, + ); + + testWidgets('german texts and the sentence with both links', ( + tester, + ) async { + await open(tester); + + expect(find.text(_de.authFieldPasswordLabel), findsOneWidget); + final handle = tester.ensureSemantics(); + expect( + find.bySemanticsLabel( + 'Ich habe die Datenschutzerklärung gelesen und stimme den ' + 'Nutzungsbedingungen zu', + ), + findsOneWidget, + ); + handle.dispose(); + }); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, themeMode: mode); + await submit(tester); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, textScale: 2, size: const Size(320, 640)); + await submit(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wide screens show a centered card', (tester) async { + await open(tester, size: const Size(1200, 900)); + + final card = tester.getRect(find.byType(Card).first); + expect(card.width, lessThanOrEqualTo(480)); + expect(card.center.dx, closeTo(600, 1)); + }); + }); +} diff --git a/docs/design-system.md b/docs/design-system.md index 119f4e0..0a8dab5 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,7 +1,7 @@ # Design-System (Theme-Basis, F6 / Issue #31) -Stand: Basis. Konkrete Komponenten (Formularfelder, Banner, Spiel-Kachel usw.) folgen in U1. -Code: `app/lib/core/theme/` (`app_theme.dart`, `app_spacing.dart`), Tests: `app/test/core/theme/`. +Stand: Basis + Formular-Komponenten (U1). Weitere Komponenten (Spiel-Kachel, Listen, Leer-/Ladezustände) folgen. +Code: `app/lib/core/theme/` (`app_theme.dart`, `app_spacing.dart`), `app/lib/core/widgets/`; Tests: `app/test/core/theme/`, `app/test/core/widgets/`. ## Theme @@ -60,3 +60,23 @@ wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth FilledButton, ElevatedButton, OutlinedButton, TextButton, IconButton, Checkbox, InputDecoration (gefüllt, Rahmen 1 dp, Fokus 3 dp, Fehler 2 dp), Card, AppBar, Divider, SnackBar (schwebend, mit Schließen-Icon). + +## Formular-Komponenten (`app/lib/core/widgets/`, U1) + +Gemeinsame Regeln: Alle Texte (Labels, Fehler, Semantics-Labels) kommen als Parameter von außen (l10n), nie +hart codiert. Fehler = Icon + Text + Fehlerrahmen, nie nur Farbe. Fehlertexte sind Live-Regionen (werden von +Screenreadern angesagt). Layout bricht bei 200 % Textskalierung um statt abzuschneiden. Alle Komponenten sind +in Hell und Dunkel gegen `androidTapTargetGuideline`, `labeledTapTargetGuideline` und `textContrastGuideline` +getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vorschau. + +| Komponente | Zweck und API (Kurzform) | +|---|---| +| `AppTextField` | `label` (sichtbar über dem Feld, umbrechend, auch Semantics-Label), `helperText`, `errorText` (ersetzt Hilfetext), `controller`, `focusNode`, `keyboardType`, `textInputAction`, `autofillHints`, `maxLength` + `showCounter` (weiche Grenze: Eingabe wird nicht abgeschnitten, damit der Fehler am Feld erscheint), `counterText` (eigener Zählertext, wenn die Zähleinheit vom Standard abweicht, z. B. getrimmte Codepoints beim Anzeigenamen), `inputFormatters`, `enabled`, `onChanged`, `onSubmitted`. | +| `AppPasswordField` | Wie `AppTextField`, dazu `showPasswordLabel` / `hidePasswordLabel` (Pflicht; Tooltip + Semantics des 48-dp-Schalters), `ruleHint` (Regelhinweis, solange kein Fehler), `isNewPassword` (Autofill `newPassword` statt `password`), `autofillHints` zum Überschreiben. Startet verdeckt, Autokorrektur und Vorschläge aus. | +| `AppCheckboxField` | `AppCheckboxField(value, onChanged, label)` bzw. `.rich(segments: [AppLabelSegment.text(..), AppLabelSegment.link(text, onTap)])`, `errorText`. Ganze Zeile (Checkbox + Label) ist Touch-Ziel >= 48 dp. Links sind per Tastatur fokussierbar und für Screenreader als benutzerdefinierte Aktionen verfügbar; Semantics-Knoten meldet Zustand und den vollen Labeltext. | +| `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). | +| `AppFormScaffold` | `child`, `appBar`, `maxWidth` (Standard `AppBreakpoints.authCardMaxWidth`). Kompakt: Vollbild, scrollbar; ab 600 dp zentrierte Karte. Tastatur schiebt den Inhalt hoch (Scaffold-Resize), Ziehen schließt die Tastatur. | +| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton). Live-Region mit „typeLabel: message“. | +| `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. | + +Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`. diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md new file mode 100644 index 0000000..396cd9d --- /dev/null +++ b/docs/design/auth-screens.md @@ -0,0 +1,218 @@ +# Auth-Screens: Layout-Skizzen und Texte (Spec #1, Task U1) + +Grundlage: `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.4 Meldungen, § 5.4 Screens, § 6 Plattform). +Komponenten: `app/lib/core/widgets/` (siehe `docs/design-system.md`). Alle Texte unten sind +**Vorschläge** und gehen über l10n (ARB); die ARB-Dateien pflegt der flutter-dev (A1/A2). +Schlüssel sind `lowerCamelCase` mit Präfix `auth`. + +## 1. Gemeinsames Layout + +- Alle sechs Screens nutzen `AppFormScaffold`. Kompakt (< 600 dp): Vollbild, scrollbar, Seitenrand 16 dp. + Ab 600 dp: zentrierte Karte, max. 480 dp breit (`AppBreakpoints.authCardMaxWidth`), Innenabstand 24 dp. +- Inhalt in einer Spalte, Abstand zwischen Feldern 16 dp, zwischen Gruppen 24 dp. +- Reihenfolge = Lesereihenfolge = Tab-Reihenfolge: Titel, Banner (falls vorhanden), Felder, Checkboxen, + Primärknopf, Sekundärlinks. +- Titel: `headlineSmall`, darunter optional ein Einleitungssatz in `bodyMedium`. Titel als Semantics-Überschrift + (`Semantics(header: true)`). +- Banner (`AppBanner`) stehen **über** den Feldern, direkt unter dem Titel. Fehler am Feld stehen unter dem Feld. +- Genau ein Primärknopf (`AppPrimaryButton`, volle Breite). Sekundäraktionen sind `TextButton`s (48 dp). +- Eingaben bleiben bei jedem Fehler erhalten. Während `submitting` sind Knopf und Felder gesperrt, der Knopf + zeigt den Spinner und den gesprochenen Text `authSubmitting`. +- Nach einem Fehler setzt der Screen den Fokus auf das erste fehlerhafte Feld; bei Banner-Fehlern bleibt der + Fokus am Knopf, das Banner wird als Live-Region angesagt. +- Enter im letzten Feld sendet ab (`TextInputAction.done`), sonst `next`. + +Legende der Skizzen: `[ ]` Feld, `[x]` Checkbox, `( )` Primärknopf, `<…>` Link/TextButton, `!` Banner. + +## 2. Skizzen + +### RegisterScreen (`/register`) + +``` +Konto erstellen (Titel) +! Fehler-Banner (nur bei registrationRejected / network / rateLimited / unexpected) +E-Mail +[ name@example.com ] Fehler: authEmailInvalid +Passwort +[ •••••••••• (Auge) ] Hinweis: authPasswordRule / Fehler je Regel +Anzeigename +[ Alex 4/30 ] Fehler: authDisplayNameEmpty | TooLong | Invalid +[x] Ich bin mindestens 16 Jahre alt Fehler: authAgeNotConfirmed +[x] Ich habe die gelesen und stimme den zu + Fehler: authTermsNotAccepted +( Konto erstellen ) + +``` + +### CheckEmailScreen (`/register/check-email`, Modus Bestätigung bzw. Reset) + +``` +Prüfe dein Postfach (Titel) +Wir haben dir eine E-Mail geschickt, falls ... (Text, bodyLarge) +Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach. +! Info-Banner "E-Mail wurde erneut gesendet" (nach Resend, auch bei emailSendLimited) +! Fehler-Banner bei rateLimited / network +(nur wenn E-Mail fehlt, z. B. nach Reload:) E-Mail [ ] +( E-Mail erneut senden ) -> nur Modus Bestätigung, gesperrt solange Anfrage läuft + (Modus Reset: -> /forgot-password) +``` + +### LoginScreen (`/login`) + +``` +Anmelden +! Fehler-Banner: authInvalidCredentials | rateLimited | network (über dem Formular, nie am Feld) +! Info-Banner emailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden" +E-Mail [ ] +Passwort [ ••••• (Auge) ] +( Anmelden ) + + +``` + +### ForgotPasswordScreen (`/forgot-password`) + +``` +Passwort zurücksetzen +Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen. +E-Mail [ ] Fehler: authEmailInvalid +( Link senden ) + +``` + +### EmailLinkScreen (`/auth/confirm`) + +Zustände: `idle` (Knopf), `submitting`, `success` (nur Bestätigung), `error` (Link ungültig). + +``` +E-Mail-Adresse bestätigen | Neues Passwort festlegen (Titel je Typ) +Ein Klick genügt, um fortzufahren. +( E-Mail-Adresse bestätigen ) | ( Weiter zum neuen Passwort ) + +Erfolg: ! Info-Banner "E-Mail bestätigt" (+ Mobile-Web: "Du kannst dich jetzt auch in der App anmelden.") +Fehler: ! Fehler-Banner "Der Link ist abgelaufen ..." ( Neuen Link anfordern ) +``` + +### ResetPasswordScreen (`/reset-password`) + +``` +Neues Passwort festlegen +Neues Passwort +[ ••••••••• (Auge) ] Live-Regeln: authPasswordRule, Fehler je Regel / samePassword +( Passwort speichern ) + -> signOut +Erfolg: Hinweis authPasswordChanged, dann Weiterleitung nach / +``` + +## 3. Textvorschläge + +Ton: du-Form, kurz, sachlich-freundlich, keine Schuldzuweisung. Platzhalter in ICU-Syntax (`{n}`). +Neutrale Texte (Krit. 4, F12) verraten nie, ob eine Adresse registriert ist. + +### 3.1 Meldungen aus § 4.4 (Fehlerabbildung) + +| Schlüssel | de | en | +|---|---|---| +| `authErrorInvalidCredentials` | E-Mail-Adresse oder Passwort ist nicht korrekt. | Email address or password is incorrect. | +| `authErrorEmailNotConfirmed` | Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt. | Please confirm your email address first. We sent you an email for this. | +| `authActionResendConfirmation` | Bestätigungs-E-Mail erneut senden | Resend confirmation email | +| `authErrorRateLimited` | Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut. | Too many attempts. Please wait a few minutes and try again. | +| `authErrorWeakPasswordLength` | Das Passwort ist zu kurz. | The password is too short. | +| `authErrorWeakPasswordCharacters` | Das Passwort ist zu einfach. Wähle ein anderes. | The password is too simple. Choose a different one. | +| `authErrorWeakPasswordPwned` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | +| `authErrorSamePassword` | Wähle ein anderes als dein bisheriges Passwort. | Choose a password different from your current one. | +| `authErrorLinkInvalidOrExpired` | Der Link ist abgelaufen oder wurde schon benutzt. | The link has expired or has already been used. | +| `authActionRequestNewLink` | Neuen Link anfordern | Request a new link | +| `authErrorEmailInvalid` | Gib eine gültige E-Mail-Adresse ein. | Enter a valid email address. | +| `authErrorRegistrationRejected` | Registrierung nicht möglich. Bitte prüfe deine Angaben. | Registration is not possible. Please check your details. | +| `authErrorNetwork` | Keine Verbindung. Für diesen Schritt brauchst du Internet. | No connection. You need internet access for this step. | +| `authErrorUnexpected` | Etwas ist schiefgelaufen. Bitte versuche es erneut. | Something went wrong. Please try again. | +| `authActionRetry` | Erneut versuchen | Try again | + +`emailSendLimited` hat bewusst **keinen** eigenen Text: Register/Forgot/CheckEmail zeigen den neutralen Erfolgstext +(§ 3.3), nur der Resend im LoginScreen zeigt `authErrorRateLimited`. + +### 3.2 Meldungen aus § 5.4 (Felder, Banner, Hinweise) + +| Schlüssel | de | en | +|---|---|---| +| `authPasswordTooShort` | Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}. | At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}. | +| `authPasswordCommon` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | +| `authPasswordRule` | Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort. | At least 10 characters. Not a very common password. | +| `authPasswordRequired` | Gib ein Passwort ein. | Enter a password. | +| `authDisplayNameEmpty` | Gib einen Anzeigenamen ein. | Enter a display name. | +| `authDisplayNameTooLong` | Der Anzeigename darf höchstens {max} Zeichen lang sein. | The display name can be at most {max} characters long. | +| `authDisplayNameInvalid` | Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen. | This display name is not allowed. Use visible characters. | +| `authAgeNotConfirmed` | Shuffle ist erst ab 16 Jahren nutzbar. | Shuffle can only be used from age 16. | +| `authTermsNotAccepted` | Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu. | Please agree to the privacy policy and the terms of use. | +| `authFieldEmailLabel` | E-Mail-Adresse | Email address | +| `authFieldPasswordLabel` | Passwort | Password | +| `authFieldNewPasswordLabel` | Neues Passwort | New password | +| `authFieldDisplayNameLabel` | Anzeigename | Display name | +| `authPasswordShow` | Passwort anzeigen | Show password | +| `authPasswordHide` | Passwort verbergen | Hide password | +| `authCheckboxAgeLabel` | Ich bin mindestens 16 Jahre alt | I am at least 16 years old | +| `authCheckboxTermsPrefix` | Ich habe die | I have read the | +| `authCheckboxTermsPrivacyLink` | Datenschutzerklärung | privacy policy | +| `authCheckboxTermsMiddle` | gelesen und stimme den | and agree to the | +| `authCheckboxTermsTermsLink` | Nutzungsbedingungen | terms of use | +| `authCheckboxTermsSuffix` | zu | (leer) | +| `authBannerTypeError` | Fehler | Error | +| `authBannerTypeInfo` | Hinweis | Note | +| `authSubmitting` | Bitte warten … | Please wait … | + +Hinweis zur Checkbox „Nutzungsbedingungen“: Satzbau ist je Sprache verschieden. Der Satz wird deshalb aus den +Segmenten `Prefix`, `PrivacyLink`, `Middle`, `TermsLink`, `Suffix` zusammengesetzt (`AppCheckboxField.rich`); +im Englischen bleibt `Suffix` leer. Alternativ ein ICU-Satz mit Platzhaltern, den der Screen an den Linkstellen +teilt – Entscheidung beim flutter-dev (A2). + +### 3.3 Screen-Texte + +| Schlüssel | de | en | +|---|---|---| +| `authRegisterTitle` | Konto erstellen | Create account | +| `authRegisterSubmit` | Konto erstellen | Create account | +| `authRegisterHaveAccount` | Schon ein Konto? Anmelden | Already have an account? Sign in | +| `authCheckEmailTitle` | Prüfe dein Postfach | Check your inbox | +| `authCheckEmailBody` | Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren. | We sent you an email if the address is not registered yet. Follow the link in it to continue. | +| `authCheckEmailBodyReset` | Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen geschickt, falls zu dieser Adresse ein Konto besteht. | We sent you an email with a reset link if an account exists for this address. | +| `authCheckEmailDelayHint` | Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach. | The email can take a few minutes. Also check your spam folder. | +| `authCheckEmailValidity` | Der Link ist {duration} gültig. | The link is valid for {duration}. | +| `authCheckEmailValidityOneHour` | 1 Stunde | 1 hour | +| `authCheckEmailResend` | E-Mail erneut senden | Resend email | +| `authCheckEmailResent` | Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt. | We resent the email, if there is something to send for this address. | +| `authCheckEmailDifferentAddress` | Andere E-Mail-Adresse eingeben | Enter a different email address | +| `authBackToLogin` | Zurück zur Anmeldung | Back to sign in | +| `authLoginTitle` | Anmelden | Sign in | +| `authLoginSubmit` | Anmelden | Sign in | +| `authLoginForgotPassword` | Passwort vergessen? | Forgot password? | +| `authLoginCreateAccount` | Konto erstellen | Create account | +| `authForgotTitle` | Passwort zurücksetzen | Reset password | +| `authForgotBody` | Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen. | Enter the email address of your account. We will send you a reset link. | +| `authForgotSubmit` | Link senden | Send link | +| `authConfirmTitle` | E-Mail-Adresse bestätigen | Confirm email address | +| `authConfirmBody` | Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen. | One click is enough to confirm your email address. | +| `authConfirmSubmit` | E-Mail-Adresse bestätigen | Confirm email address | +| `authConfirmSuccess` | E-Mail bestätigt. | Email confirmed. | +| `authConfirmSuccessMobileWeb` | Du kannst dich jetzt auch in der App anmelden. | You can now also sign in in the app. | +| `authRecoveryTitle` | Neues Passwort festlegen | Set a new password | +| `authRecoveryBody` | Ein Klick genügt, um ein neues Passwort festzulegen. | One click is enough to set a new password. | +| `authRecoverySubmit` | Weiter zum neuen Passwort | Continue to new password | +| `authResetTitle` | Neues Passwort festlegen | Set a new password | +| `authResetSubmit` | Passwort speichern | Save password | +| `authResetCancel` | Abbrechen | Cancel | +| `authPasswordChanged` | Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern. | Password changed. Other devices will be signed out; this can take up to an hour. | + +## 4. Barrierefreiheit pro Screen (Checkliste für A2–A4 und T2) + +- Jedes Feld hat ein sichtbares Label (kein Platzhalter-Label); Fehler per `errorText` (Icon + Text + Live-Region). +- Banner und Fehler werden beim Erscheinen vorgelesen (Live-Region); keine reinen Farb-Signale. +- Passwortfelder: `AppPasswordField` mit `isNewPassword: true` bei Register/Reset, sonst `false`; E-Mail mit + `AutofillHints.email` (Login: `AutofillHints.username`/`email`), `TextInputType.emailAddress`. +- Links in Checkbox-Sätzen (`AppCheckboxField.rich`) sind eigene Semantics-Knoten (`link: true`, Label = Linktext, + Tap), keine Custom Actions (Flutter-Web setzt diese nicht in ARIA um). Der Checkbox-Knoten trägt den ganzen Satz. +- Meldungen, die sich bei jedem Tastendruck ändern („es fehlen noch n“), sind während des Tippens keine Live-Region + (`liveError: false`) und werden beim Verlassen des Feldes angesagt; der sichtbare Text bleibt. +- Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar. +- Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`, + `labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2). diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 59198a0..3998809 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -76,13 +76,13 @@ Nur additiv: `create or replace function` und neue Objekte, keine Änderung an b | Tabelle/Objekt | Spalte | Typ | Hinweis | |---|---|---|---| -| `public.profiles` | `display_name` | text | **neuer Check** `profiles_display_name_not_blank`: `char_length(btrim(display_name)) between 1 and 30`. Er gilt auch für KTO-02 (Updates). Bestehende Seed-Zeilen erfüllen ihn. **Zweiter Check** `profiles_display_name_no_zwsp`: `strpos(display_name, chr(8203)) = 0` (U+200B verboten, Update → 23514). Seeds und Bestand enthalten kein U+200B. | +| `public.profiles` | `display_name` | text | **neuer Check** `profiles_display_name_not_blank`: `char_length(public.trim_ws(display_name)) between 1 and 30`. Er gilt auch für KTO-02 (Updates). Bestehende Seed-Zeilen erfüllen ihn. **Zweiter Check** `profiles_display_name_no_zwsp`: `strpos(display_name, chr(8203)) = 0` (U+200B verboten, Update → 23514). Seeds und Bestand enthalten kein U+200B. | | `public.profiles` | `terms_version` | text, nullable | **neue Spalte** (F5): Version der zugestimmten Rechtstexte, z. B. `2026-11-draft`. Check `terms_version is null or char_length(terms_version) between 1 and 50`. Nullable nur wegen bereits vorhandener Zeilen (nur Entwicklung); für neue Konten erzwingt der Trigger einen Wert. **Nicht** in den Update-Rechten von `authenticated`, der Nutzer kann sie also nicht ändern. Der Zeitstempel der Zustimmung steht bereits in `terms_accepted_at` (Trigger setzt `now()`). Eine erneute Zustimmung nach einer Änderung der Rechtstexte ist nicht Teil von R1 (siehe 9.4). | | `public.profiles` | `locale` | text | **Standard geändert** auf `'en'` (`alter column locale set default 'en'`, F6). Der Check `('de','en')` bleibt. | -| `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := btrim(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := btrim(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). | +| `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := public.trim_ws(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := public.trim_ws(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). | | `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. | -**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(btrim(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). Bekannte Abweichung: Dart-`trim()` entfernt auch Unicode-Leerraum (z. B. NBSP), `btrim` nur Leerzeichen. Der Client sendet bereits getrimmte Werte, die Abweichung ist deshalb nur über die rohe API erreichbar und unkritisch. **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen. +**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(public.trim_ws(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). `trim_ws` entfernt denselben Leerraum wie Dart-`trim()` (auch Unicode-Leerraum wie NBSP), Client und Server sind damit gleichwertig; es gibt keine bekannte Abweichung mehr (frühere Fassung: `btrim` nur Leerzeichen, siehe § 10). **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen. **Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue. @@ -265,7 +265,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - Erfolg (jede 200-Antwort **und** `emailSendLimited`, F12) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4). - `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP). -**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. +**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. go_router 18 würde `extra` per JSON in den Browser-Verlaufszustand (History-State) schreiben (`RouteMatchListCodec`, `match.dart`); das verhindert der `VolatileExtraCodec` am Router (`extraCodec`, `extra` bleibt nur im Speicher). Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. **LoginScreen** (01b, Krit. 5, 6, 7) @@ -291,12 +291,13 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). - `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. +- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). - Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue` bzw. freezed-Formzustand. Keine Logik in Widgets. ### 5.6 Liste häufiger Passwörter (Krit. 3) - Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel. -- Quelle und Lizenz prüft `test-engineer` in Aufgabe A1 und dokumentiert sie in `app/assets/security/README.md`. Kandidat: SecLists (MIT); deutsche Ergänzung mit Quellenangabe. Keine Liste ohne klare Lizenz. +- Quelle und Lizenz dokumentiert `app/assets/security/README.md`. **Entschieden (Chris, 2026-11-03):** Die SecLists-Listen (MIT, Herkunft dokumentiert, Commit gepinnt) werden genutzt; die Frage, dass einzelne Listen aus Datenlecks bzw. Sammlungen Dritter stammen, ist damit abgenommen (die Liste enthält nur Passwörter, keine Konten oder E-Mail-Adressen). Der MIT-Text steht in `app/THIRD_PARTY.md`, wird als Asset gebündelt und zur Laufzeit über `LicenseRegistry` in `showLicensePage` ausgeliefert. - Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht. ### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02) @@ -492,3 +493,15 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien 12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst. 13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt). 14. **Anzeigename: U+200B verboten (Chris, 2026-11-02):** Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger `display_name_invalid`, zusätzlicher Check `profiles_display_name_no_zwsp`, Update → 23514; Migration `20261102212208_auth_registration_rules.sql`, pgTAP `05_auth_registration`). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a: `DisplayNameRule` meldet U+200B als Feldfehler, gleiche Meldung wie „ungültig"). + +### 2026-11-03 – Review-Findings zu 01a (Branch `feature/1-01a-registrierung`) + +1. **Passwortliste, Lizenz entschieden (Chris, 2026-11-03):** SecLists (MIT) wird genutzt, Herkunft in `app/assets/security/README.md` dokumentiert (5.6). Der Lizenztext wird zur Laufzeit in `showLicensePage` ausgeliefert (`LicenseRegistry`, Asset `THIRD_PARTY.md`). +2. **Rechtstext-Links im Web:** Die Links der Einwilligungs-Checkbox sind eigene Semantics-Knoten (`link: true`, Label, Tap) statt `CustomSemanticsAction` am Checkbox-Knoten; Flutter-Web setzt zusätzliche Aktionen nicht in ARIA um. Checkbox-Knoten behält Label, Zustand und Tap. +3. **Externe URLs:** `UrlOpener` öffnet nur `http`/`https` mit Host (kein `javascript:`, `file:`, `intent:`). +4. **Doppel-Submit:** `RegisterController.submit` setzt `submitting` vor dem Laden der Passwortliste. +5. **Transportfehler:** Erkennung per `is` (`http.ClientException`, `dart:io`-Fehler über bedingten Import) statt `runtimeType.toString()` (bricht in minifiziertem dart2js). `http` ist als direkte Abhängigkeit deklariert (`1.6.0`, bereits transitiv im Lock; keine neue Fremdabhängigkeit). +6. **Trim-Regel (3.2):** Der Satz „Bekannte Abweichung: Dart-`trim()` … `btrim` nur Leerzeichen“ war falsch. Der Server nutzt `trim_ws` (Abschnitt 3.2), das Verhalten ist mit Dart-`trim()` identisch. Satz korrigiert. +7. **Recovery-Guard (5.5):** Abweichung vom Wortlaut dokumentiert: `tokenRefreshed`/`signedIn` heben `passwordRecovery` nicht auf. +8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert). +9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab. diff --git a/scripts/build-common-passwords.py b/scripts/build-common-passwords.py new file mode 100644 index 0000000..b12d9a0 --- /dev/null +++ b/scripts/build-common-passwords.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Builds app/assets/security/common-passwords.txt from SecLists (MIT). + +Usage: + build-common-passwords.py + +Inputs (SecLists commit 68bcd12457978649d41ae4f56e3b5d2db2b72560, +Passwords/Common-Credentials/): + xato-net-10-million-passwords-1000000.txt + Language-Specific/German_common-password-list-top-100000.txt + +Rules: lowercase, trimmed, at least 10 code points (shorter ones are caught by +the length rule), printable, unique. First GERMAN_COUNT entries of the German +list (frequency order), then XATO_COUNT entries of the xato list. +""" +import sys + +GERMAN_COUNT = 4000 +XATO_COUNT = 6000 +MIN_LENGTH = 10 + + +def take(path, limit, seen): + out = [] + with open(path, encoding="utf-8", errors="ignore") as handle: + for line in handle: + entry = line.strip().lower() + if ( + len(entry) >= MIN_LENGTH + and entry.isprintable() + and not entry.startswith("#") + and entry not in seen + ): + seen.add(entry) + out.append(entry) + if len(out) >= limit: + break + return out + + +def main(): + xato, german, target = sys.argv[1:4] + seen = set() + entries = take(german, GERMAN_COUNT, seen) + take(xato, XATO_COUNT, seen) + with open(target, "w", encoding="utf-8", newline="\n") as handle: + handle.write("\n".join(entries) + "\n") + print(len(entries)) + + +if __name__ == "__main__": + main()