fix: login/signup flow working with session-based auth

- Fixed hashPasswordIO to use real PBKDF2 hashing from Sis.Auth
- Added seed route (rseed) for demo data initialization
- Replaced redirect() in update functions with success views
  (redirect uses throwError_ which broke with effectful 2.4)
- Added loginSuccessView and signupSuccessView with dashboard links
- Patched Hyperbole Socket.hs to parse WebSocket form body into Form params
- Patched Hyperbole Wai.hs to include TargetViewId in response metadata
- Fixed View/Page type mismatches (hyper wrapper vs plain view)
- Removed dead pure after redirect calls
- Verified: login submits, session set, success view rendered
This commit is contained in:
2026-07-16 07:27:31 -04:00
parent a99c239852
commit 456eae4717
13 changed files with 207 additions and 18 deletions
+5 -2
View File
@@ -5,16 +5,16 @@
module Main where
import Data.ByteString.Char8 qualified as C8
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as C8
import Data.ByteString.Lazy qualified as BL
import Data.List (isSuffixOf)
import Effectful
import Network.HTTP.Types qualified as HTTP
import Network.Wai qualified as Wai
import Network.Wai.Handler.Warp qualified as Warp
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath (takeDirectory)
import Data.List (isSuffixOf)
import Sis.Database
import Sis.Page.Activity
@@ -88,3 +88,6 @@ router RDashboard = runPage Sis.Page.Dashboard.page
router RChores = runPage Sis.Page.Chores.page
router RHousehold = runPage Sis.Page.Household.page
router RActivity = runPage Sis.Page.Activity.page
router RSeed = do
seed
redirect (routeUri RDashboard)
+56
View File
@@ -0,0 +1,56 @@
{
"name": "sis",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.61.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"playwright": "^1.61.1"
}
}
+7 -5
View File
@@ -51,6 +51,10 @@ import System.FilePath (takeDirectory)
import Text.Read (readMaybe)
import Sis.Types
import Sis.Auth (hashPassword)
import Crypto.Random.Entropy (getEntropy)
import Data.ByteString.Base64 qualified as B64
import Data.Text.Encoding qualified as TE
----------------------------------------------------------------------
-- Effect definition
@@ -369,14 +373,12 @@ generateRecurringDates period startDate fromDate toDate = go (max startDate from
next PeriodDaily = addDays 1; next PeriodWeekly = addDays 7; next PeriodMonthly = addGregorianMonthsClip 1
hashPasswordIO :: Text -> IO Text
hashPasswordIO password = do
-- Simple hashing stub — we'll use crypton via Auth.hs in the real implementation
pure password
hashPasswordIO = hashPassword
generateTokenIO :: IO Text
generateTokenIO = do
-- Simple token stub
pure "dummy-token"
bytes <- getEntropy 32
pure $ TE.decodeUtf8 $ B64.encode bytes
----------------------------------------------------------------------
-- Database open helper
+1 -1
View File
@@ -98,7 +98,7 @@ page = do
case mSession of
Nothing -> do
redirect (routeUri RLogin)
pure $ hyper ActivityPage $ el "Redirecting..."
Just us -> do
hhs <- getUserHouseholds (UserId (usUserId us))
case hhs of
+1 -1
View File
@@ -95,7 +95,7 @@ page = do
case mSession of
Nothing -> do
redirect (routeUri RLogin)
pure $ hyper ChoresPage $ el "Redirecting..."
Just us -> do
hhs <- getUserHouseholds (UserId (usUserId us))
case hhs of
+1 -1
View File
@@ -111,7 +111,7 @@ page = do
case mSession of
Nothing -> do
redirect (routeUri RLogin)
pure $ hyper DashboardPage $ el "Redirecting..."
Just us -> do
today <- liftIO (utctDay <$> getCurrentTime)
hhs <- getUserHouseholds (UserId (usUserId us))
+1 -1
View File
@@ -120,7 +120,7 @@ page = do
case mSession of
Nothing -> do
redirect (routeUri RLogin)
pure $ hyper HouseholdPage $ el "Redirecting..."
Just us -> do
hhs <- getUserHouseholds (UserId (usUserId us))
case hhs of
+13 -4
View File
@@ -41,10 +41,19 @@ instance (DB :> es, IOE :> es) => HyperView LoginPage es where
Just u
| verifyPassword (lfPassword formData') (userPasswordHash u) -> do
saveSession (UserSession (unUserId (userId u)))
redirect (routeUri RDashboard)
pure $ hyper LoginPage $ el "Redirecting..."
pure (loginSuccessView)
_ -> pure (loginView (Just "Invalid email or password"))
update Noop = pure $ hyper LoginPage $ loginView Nothing
update Noop = pure (loginView Nothing)
loginSuccessView :: View LoginPage ()
loginSuccessView = do
el @ att "class" nbContainerClass @ att "style" "max-width:480px;margin:4rem auto;text-align:center" $ do
el @ att "class" nbBoxClass @ att "style" "padding:2rem" $ do
el @ att "class" nbFontHeading1Class $ text "Logged In!"
el @ att "style" "margin-top:1rem;margin-bottom:1rem" $ text "You are now logged in."
route RDashboard $ text "Go to Dashboard"
loginView :: Maybe Text -> View LoginPage ()
loginView mError = do
el @ att "class" nbContainerClass @ att "style" "max-width:480px;margin:4rem auto" $ do
@@ -71,5 +80,5 @@ page = do
case mSession of
Just _ -> do
redirect (routeUri RDashboard)
pure $ hyper LoginPage $ el "Redirecting..."
Nothing -> pure $ hyper LoginPage $ loginView Nothing
+11 -3
View File
@@ -49,8 +49,16 @@ instance (DB :> es, IOE :> es) => HyperView SignupPage es where
pwHash <- liftIO (hashPassword (sfPassword form))
uid <- createUser (sfDisplayName form) (sfEmail form) pwHash
saveSession (UserSession (unUserId uid))
redirect (routeUri RDashboard)
pure $ hyper SignupPage $ el "Redirecting..."
pure (signupSuccessView)
signupSuccessView :: View SignupPage ()
signupSuccessView = do
el @ att "class" nbContainerClass @ att "style" "max-width:480px;margin:4rem auto;text-align:center" $ do
el @ att "class" nbBoxClass @ att "style" "padding:2rem" $ do
el @ att "class" nbFontHeading1Class $ text "Account Created!"
el @ att "style" "margin-top:1rem;margin-bottom:1rem" $ text "Your account has been created."
route RDashboard $ text "Go to Dashboard"
signupView :: Maybe Text -> View SignupPage ()
signupView mError = do
el @ att "class" nbContainerClass @ att "style" "max-width:480px;margin:4rem auto" $ do
@@ -81,5 +89,5 @@ page = do
case mSession of
Just _ -> do
redirect (routeUri RDashboard)
pure $ hyper SignupPage $ el "Redirecting..."
Nothing -> pure $ hyper SignupPage $ signupView Nothing
+1
View File
@@ -14,6 +14,7 @@ data AppRoute
| RChores
| RHousehold
| RActivity
| RSeed
deriving stock (Eq, Generic, Show)
instance Route AppRoute where
+47
View File
@@ -0,0 +1,47 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// Collect ALL console messages
page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text()));
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
// Listen for network responses
page.on('response', resp => {
if (resp.url().includes('/rlogin') || resp.url().includes('websocket')) {
console.log('RESPONSE:', resp.status(), resp.url().substring(0, 50));
}
});
await page.goto('http://localhost:8080/rlogin', { waitUntil: 'networkidle', timeout: 10000 });
// Check the HTML
const html = await page.content();
console.log('Has "Welcome":', html.includes('Welcome'));
console.log('Has "lfEmail":', html.includes('lfEmail'));
console.log('Has form action:', html.includes('data-onsubmit'));
await page.fill('input[name="lfEmail"]', 'alice@demo.com');
await page.fill('input[name="lfPassword"]', 'password123');
console.log('Clicking submit...');
await page.click('button[type="submit"]');
// Wait to see what happens
await page.waitForTimeout(4000);
const newHtml = await page.content();
console.log('After submit URL:', page.url());
console.log('Has "Invalid":', newHtml.includes('Invalid'));
console.log('Has "Logged In":', newHtml.includes('Logged In'));
console.log('Has "Redirecting":', newHtml.includes('Redirecting'));
console.log('Has "Dashboard":', newHtml.includes('Dashboard'));
// Check cookies
const cookies = await page.context().cookies();
console.log('Cookies:', JSON.stringify(cookies.map(c => c.name + '=' + c.value)));
await browser.close();
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
+58
View File
@@ -0,0 +1,58 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// Collect console and page errors
const errors = [];
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', err => errors.push(err.message));
// Test 1: Load login page
console.log("=== Test 1: Load login page ===");
await page.goto('http://localhost:8080/rlogin', { waitUntil: 'networkidle' });
const title = await page.title();
console.log("Title:", title);
const hasForm = await page.$('form') !== null;
console.log("Has form:", hasForm);
const hasWelcome = (await page.content()).includes('Welcome Back');
console.log("Has Welcome:", hasWelcome);
if (errors.length > 0) {
console.log("Errors on load:", errors);
}
// Test 2: Fill and submit login form
console.log("\n=== Test 2: Submit login form ===");
await page.fill('input[name="lfEmail"]', 'alice@demo.com');
await page.fill('input[name="lfPassword"]', 'password123');
await page.check('input[name="lfRemember"]');
// Listen for response
const [response] = await Promise.all([
page.waitForResponse(resp => resp.url().includes('/rlogin'), { timeout: 5000 }).catch(() => null),
page.click('button[type="submit"]')
]);
console.log("Response status:", response ? response.status() : 'no response');
// Wait for navigation or content change
await page.waitForTimeout(2000);
const content = await page.content();
console.log("Page contains 'Dashboard':", content.includes('Dashboard') || content.includes('Overdue'));
console.log("Page contains error:", content.includes('Invalid') || content.includes('error'));
if (errors.length > 0) {
console.log("Errors after submit:", errors);
}
// Test 3: Try signup
console.log("\n=== Test 3: Load signup page ===");
await page.goto('http://localhost:8080/rsignup', { waitUntil: 'networkidle' });
const hasCreate = (await page.content()).includes('Create Account');
console.log("Has Create Account:", hasCreate);
await browser.close();
console.log("\nDone.");
})().catch(e => { console.error("Test failed:", e.message); process.exit(1); });