Apps Script用Sheet生成動態網頁(21): 移植會員登入頁面

接著我們來移植在第9篇我們製作的會員登入頁面,這裡為了順著React範本的整理方式,所以會需要寫調整很多程式碼的風格與寫法。


1. Apps Script的部份(後端/伺服端)

增加權限

因為會員登入需要用到Google sheet,所以要加入sheet存取權限,我們修改appsscript.json的oauthScopes

"oauthScopes": [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/spreadsheets"
],

然後在src/server/settings.js裡頭新增sheet的URL,以及我們會員輸入檢查的兩個正規表示式
export const SHEET_URL =
'https://docs.google.com/spreadsheets/d/1w6X...6aQ/edit#gid=0';
export const RE_ACCOUNT = /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z]+$/;
export const RE_PASSWORD = /^[A-Za-z][A-Za-z0-9]{7,15}$/;

新增API

因為先前寫的比較零散,這裡將使用者相關的API放置在同一模組裡頭,所以會引用模組user。而暴露給前端的API,就引用user內的login和auth函數,為了相容之前的API所以還是取名loginUser和authLogin。

import * as userFunctions from './user';
...
global.loginUser = userFunctions.login;
global.authLogin = userFunctions.auth;


在src/server/user.js內移植之前在實作的登入login()和驗證使用者的auth()。

import { RE_ACCOUNT, RE_PASSWORD } from './settings';
import { getUserSheet, findIndexInColumn } from './sheet';
import createJwt from './jwt';

export const COLUMN_IDX_OF_NAME = 0;
export const COLUMN_IDX_OF_PWD = 1;
export const COLUMN_IDX_OF_ACCESSTOKEN = 2;
export const COLUMN_IDX_OF_CONFIRMED = 3;

export function login(form) {
const name = form.username;
if (typeof name !== 'string' || !RE_ACCOUNT.test(name))
throw new Error('帳號格式錯誤:應是E-mail');
const { password } = form;
if (typeof password !== 'string' || !RE_PASSWORD.test(password))
throw new Error(
'密碼格式錯誤:首字需為英文,其他為大小寫英數字,長度8到16之間'
);
const sheet = getUserSheet();
const rowIdx = findIndexInColumn(name, COLUMN_IDX_OF_NAME, sheet);
if (rowIdx < 0) throw new Error('帳號或密碼錯誤');
const usrData = sheet
.getRange(1 + rowIdx, 1, 1, sheet.getLastColumn())
.getValues()[0];
if (!usrData[COLUMN_IDX_OF_CONFIRMED])
throw new Error('你已經註冊了,但是還沒點擊確認信,請查看你的信箱!');
if (password !== usrData[COLUMN_IDX_OF_PWD])
throw new Error('帳號或密碼錯誤');
const accessToken = createJwt({
privateKey: ScriptApp.getScriptId(), // 請改成你喜歡的
expiresInHours: 1,
data: {
iss: name,
user: { name, loginAt: new Date().getTime() },
host: ScriptApp.getService().getUrl(),
},
});
return { status: 201, message: '已成功登入', token: accessToken };
}

export function auth(token) {
if (!token || typeof token !== 'string') throw new Error('token arg');
const [header, payload, signature] = token.split('.');
if (!header || !payload || !signature) throw new Error('invalid jwt');
let json = {};
try {
const decoded = Utilities.base64DecodeWebSafe(payload);
json = JSON.parse(Utilities.newBlob(decoded).getDataAsString());
} catch (error) {
Logger.log(error);
throw new Error('payload');
}
if (json.host !== ScriptApp.getService().getUrl())
throw new Error(`invalid host:${json.host}`);
const nowTime = Date.now() / 1000;
if (nowTime >= json.exp)
throw new Error(`expired:${json.exp}, now:${nowTime}`);
const base64Encode = text =>
Utilities.base64EncodeWebSafe(text).replace(/=+$/, '');
const computedSignature = base64Encode(
Utilities.computeHmacSha256Signature(
`${header}.${payload}`,
ScriptApp.getScriptId() // 請改成你喜歡的
)
);
if (signature !== computedSignature)
throw new Error(`invalid signature: ${signature}, ${computedSignature}`);
return json.user;
}



這裡會發現有引用sheet和jwt,這是整理之前的實作,把sheet的操作都放在sheet.js,而jwt.js則是一樣引用自How to Create JSON Web Token (JWT) with Google Apps Script

import { getUserSheet, findIndexInColumn } from './sheet';
import createJwt from './jwt';

因為React使用ES的新版本,將每個檔案都視為是一個模組,所以在引用的jwt.js後面還需要要加上export,讓其他的js可以使用。

export default createJwt;


sheet.js的實作
import { SHEET_URL } from './settings';

export function getUserSheet() {
const USERS_SHEET_NAME = '使用者';
const book = SpreadsheetApp.openByUrl(SHEET_URL);
let sheet = book.getSheetByName(USERS_SHEET_NAME);
if (!sheet) sheet = book.insertSheet(USERS_SHEET_NAME);
return sheet;
}

export function findIndexInColumn(name, column, sheet) {
const list = sheet
.getRange(1, 1 + column, 1 + sheet.getLastRow(), 1)
.getValues();
return list.findIndex(r => name === r[column]);
}


2. React的部份(前端/使用者端)

這裡分為兩個部份,第一個部份是製作登入用表格的元件LoginForm,第二部份就是登入頁面Login的運作機制。

LoginForm本身就是排版網頁上的元素,只是改用ReactBootstrap元件,實際上React也可以直接使用Html網頁上的Tag,不見得要都用ReactBootstrap的元件。

src/client/demo-bootstrap/components/LoginForm.jsx
import React from 'react';
import PropTypes from 'prop-types';
import { Form, Row, Col, Button } from 'react-bootstrap';

const LoginForm = ({ onSubmit }) => {
return (
<Form onSubmit={onSubmit}>
<Form.Group as={Row} className="mb-3" controlId="loginAccount">
<Form.Label column sm={2}>
帳號
</Form.Label>
<Col sm={10}>
<Form.Control
type="text"
name="username"
placeholder="輸入帳號(email)"
autocomplete="username"
required
/>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="loginPassword">
<Form.Label column sm={2}>
密碼
</Form.Label>
<Col sm={10}>
<Form.Control
type="password"
name="password"
placeholder="輸入密碼"
autocomplete="current-password"
required
/>
</Col>
</Form.Group>
<Row>
<Button variant="primary" type="submit">
登入
</Button>
</Row>
</Form>
);
};

LoginForm.propTypes = {
onSubmit: PropTypes.func,
};

export default LoginForm;


登入機制部份,我修改了之前在第9篇所製作的會員機制,會在載入登入頁面時,使用後端曝露出來的authLogin溝通,檢查使用者是否已經登入,若有登入就會設定顯示訊息為"您已登入",並顯示前往首頁的連結,避免使用者會覺得奇怪要重複登入。
useEffect(() => {
const token = localStorage.getItem('user-token');
serverFunctions
.authLogin(token)
.then(() => {
setLoginState(LoginState.logined);
showText('您已登入');
showLink(true);
})
.catch(error => {
console.warn(error, token);
setLoginState(LoginState.havnt);
});
}, []);

當使用者按下登入按鈕時,會跟後端曝露出來的loginUser溝通,如登入成功就會使用localStorage儲存使用者的登入資訊。失敗時,會顯示server回傳的錯誤訊息。
const onSubmit = event => {
event.preventDefault();
showText('登入中,請稍候...');
serverFunctions
.loginUser(event.target)
.then(response => {
setLoginState(LoginState.logined);
localStorage.setItem('user-token', response.token);
showText(response.message);
showLink(true);
})
.catch(error => {
console.error(error);
setLoginState(LoginState.error);
showText(`登入失敗,錯誤訊息:${error.message}`);
});
};

src/client/demo-bootstrap/pages/Login.jsx調整實做如下
import React, { useState, useEffect } from 'react';
import { Container, Row } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import LoginForm from '../components/LoginForm';

import Server from '../../utils/server';

const { serverFunctions } = Server;

const LoginState = {
havnt: -2,
error: -1,
unknown: 0,
logined: 1,
};

const Login = () => {
const [loginState, setLoginState] = useState(LoginState.unknown);
const [msg, showText] = useState(null);
const [link, showLink] = useState(false);

useEffect(() => {
const token = localStorage.getItem('user-token');
serverFunctions
.authLogin(token)
.then(() => {
setLoginState(LoginState.logined);
showText('您已登入');
showLink(true);
})
.catch(error => {
console.warn(error, token);
setLoginState(LoginState.havnt);
});
}, []);

const onSubmit = event => {
event.preventDefault();
showText('登入中,請稍候...');
serverFunctions
.loginUser(event.target)
.then(response => {
setLoginState(LoginState.logined);
localStorage.setItem('user-token', response.token);
showText(response.message);
showLink(true);
})
.catch(error => {
console.error(error);
setLoginState(LoginState.error);
showText(`登入失敗,錯誤訊息:${error.message}`);
});
};

return (
<Container>
<h1>登入</h1>
{loginState < 0 && <LoginForm onSubmit={onSubmit} />}
<Row>{msg}</Row>
{link && <Link to="/home">點此前往首頁</Link>}
</Container>
);
};

export default Login;


3. 成果展示








留言