تسجيل الدخول في الواجهة الأمامية
ركّزنا في الجزأين الماضيين بشكل أساسي على الواجهة الخلفية. الواجهة الأمامية التي طوّرناها في الجزء 2 لا تدعم بعد إدارة المستخدمين التي نفّذناها في الواجهة الخلفية في الجزء 4.
في الوقت الحالي تعرض الواجهة الأمامية الملاحظات الموجودة وتتيح للمستخدمين تغيير حالة الملاحظة من مهمة إلى غير مهمة والعكس. ولم يعد بالإمكان إضافة ملاحظات جديدة بسبب التغييرات التي أُجريت على الواجهة الخلفية في الجزء 4: إذ تتوقع الواجهة الخلفية الآن إرسال رمز (token) يتحقق من هوية المستخدم مع الملاحظة الجديدة.
سننفّذ الآن جزءاً من وظائف إدارة المستخدمين المطلوبة في الواجهة الأمامية. لنبدأ بتسجيل دخول المستخدم. سنفترض طوال هذا الجزء أنه لن تتم إضافة مستخدمين جدد من الواجهة الأمامية.
إضافة نموذج تسجيل دخول
أُضيف الآن نموذج تسجيل دخول إلى أعلى الصفحة:

أصبحت شيفرة مكوّن App الآن كما يلي:
const App = () => {
const [notes, setNotes] = useState([])
const [newNote, setNewNote] = useState('')
const [showAll, setShowAll] = useState(true)
const [errorMessage, setErrorMessage] = useState(null)
// highlight-start
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
// highlight-end
useEffect(() => {
noteService
.getAll().then(initialNotes => {
setNotes(initialNotes)
})
}, [])
// ...
// highlight-start
const handleLogin = (event) => {
event.preventDefault()
console.log('logging in with', username, password)
}
// highlight-end
return (
<div>
<h1>Notes</h1>
<Notification message={errorMessage} />
// highlight-start
<h2>Login</h2>
<form onSubmit={handleLogin}>
<div>
<label>
username
<input
type="text"
value={username}
onChange={({ target }) => setUsername(target.value)}
/>
</label>
</div>
<div>
<label>
password
<input
type="password"
value={password}
onChange={({ target }) => setPassword(target.value)}
/>
</label>
</div>
<button type="submit">login</button>
</form>
// highlight-end
// ...
</div>
)
}
export default App
يمكن العثور على شيفرة التطبيق الحالية على GitHub، في الفرع part5-1. إذا استنسخت المستودع، لا تنسَ تشغيل npm install قبل محاولة تشغيل الواجهة الأمامية.
لن تعرض الواجهة الأمامية أي ملاحظات إذا لم تكن متصلة بالواجهة الخلفية. يمكنك تشغيل الواجهة الخلفية بالأمر npm run dev في مجلدها من الجزء 4. سيشغّل هذا الواجهة الخلفية على المنفذ 3001. وبينما هي قيد التشغيل، يمكنك في نافذة طرفية منفصلة تشغيل الواجهة الأمامية بالأمر npm run dev، وحينها يمكنك رؤية الملاحظات المحفوظة في قاعدة بيانات MongoDB من الجزء 4.
ضع هذا في اعتبارك من الآن فصاعداً.
يُعالَج نموذج تسجيل الدخول بالطريقة نفسها التي عالجنا بها النماذج في الجزء 2. تحتوي حالة التطبيق على حقلي username وpassword لتخزين بيانات النموذج. لحقول النموذج معالجات أحداث تزامن التغييرات في الحقل مع حالة مكوّن App. معالجات الأحداث بسيطة: يُمرَّر إليها كائن كوسيط، فتستخرج منه الحقل target وتحفظ قيمته في الحالة.
({ target }) => setUsername(target.value)
لم تُنفَّذ بعد الدالة handleLogin المسؤولة عن معالجة البيانات في النموذج.
إضافة منطق إلى نموذج تسجيل الدخول
يتم تسجيل الدخول بإرسال طلب HTTP POST إلى عنوان الخادم api/login. لنفصل الشيفرة المسؤولة عن هذا الطلب في وحدتها الخاصة، في الملف services/login.js.
سنستخدم صيغة async/await بدلاً من الوعود (promises) في طلب HTTP:
import axios from 'axios'
const baseUrl = '/api/login'
const login = async credentials => {
const response = await axios.post(baseUrl, credentials)
return response.data
}
export default { login }
يمكن تنفيذ دالة معالجة تسجيل الدخول كما يلي:
import loginService from './services/login'
const App = () => {
// ...
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
// highlight-start
const [user, setUser] = useState(null)
// highlight-end
// ...
const handleLogin = async event => {
event.preventDefault()
// highlight-start
try {
const user = await loginService.login({ username, password })
setUser(user)
setUsername('')
setPassword('')
} catch {
setErrorMessage('wrong credentials')
setTimeout(() => {
setErrorMessage(null)
}, 5000)
}
// highlight-end
}
// ...
}
إذا نجح تسجيل الدخول، تُفرَّغ حقول النموذج وتُحفظ استجابة الخادم (بما فيها الرمز وتفاصيل المستخدم) في الحقل user من حالة التطبيق.
وإذا فشل تسجيل الدخول أو نتج عن تشغيل الدالة loginService.login خطأ، يُخطَر المستخدم بذلك.
العرض الشرطي لنموذج تسجيل الدخول
لا يُخطَر المستخدم بأي شكل من الأشكال عند نجاح تسجيل الدخول. لنعدّل التطبيق بحيث يعرض نموذج تسجيل الدخول فقط إذا لم يكن المستخدم مسجّلاً للدخول، أي عندما user === null. ويُعرض نموذج إضافة ملاحظات جديدة فقط إذا كان المستخدم مسجّلاً للدخول، أي عندما تحتوي حالة user على تفاصيل المستخدم.
لنضف دالتين مساعدتين إلى مكوّن App لتوليد النموذجين:
const App = () => {
// ...
const loginForm = () => (
<form onSubmit={handleLogin}>
<div>
<label>
username
<input
type="text"
value={username}
onChange={({ target }) => setUsername(target.value)}
/>
</label>
</div>
<div>
<label>
password
<input
type="password"
value={password}
onChange={({ target }) => setPassword(target.value)}
/>
</label>
</div>
<button type="submit">login</button>
</form>
)
const noteForm = () => (
<form onSubmit={addNote}>
<input value={newNote} onChange={handleNoteChange} />
<button type="submit">save</button>
</form>
)
return (
// ...
)
}
ونعرضهما شرطياً:
const App = () => {
// ...
const loginForm = () => (
// ...
)
const noteForm = () => (
// ...
)
return (
<div>
<h1>Notes</h1>
<Notification message={errorMessage} />
{!user && loginForm()}
{user && noteForm()}
<div>
<button onClick={() => setShowAll(!showAll)}>
show {showAll ? 'important' : 'all'}
</button>
</div>
<ul>
{notesToShow.map(note => (
<Note
key={note.id}
note={note}
toggleImportance={() => toggleImportanceOf(note.id)}
/>
))}
</ul>
<Footer />
</div>
)
}
استُخدمت حيلة React التي تبدو غريبة قليلاً لكنها شائعة الاستخدام لعرض النموذجين شرطياً:
{!user && loginForm()}
إذا قُيّم التعبير الأول إلى false أو كان falsy (قيمة كاذبة)، فلن يُنفَّذ التعبير الثاني (توليد النموذج) إطلاقاً.
لنجرِ تعديلاً آخر. إذا كان المستخدم مسجّلاً للدخول، يُعرض اسمه على الشاشة:
return (
<div>
<h1>Notes</h1>
<Notification message={errorMessage} />
{!user && loginForm()}
// highlight-start
{user && (
<div>
<p>{user.name} logged in</p>
{noteForm()}
</div>
)}
// highlight-end
<div>
<button onClick={() => setShowAll(!showAll)}>
// ...
الحل ليس مثالياً، لكننا سنتركه على حاله الآن.
مكوّننا الرئيسي App كبير جداً في الوقت الحالي. والتغييرات التي أجريناها الآن إشارة واضحة إلى أنه ينبغي إعادة هيكلة النماذج إلى مكوّنات خاصة بها. لكننا سنترك ذلك لتمرين اختياري.
يمكن العثور على شيفرة التطبيق الحالية على GitHub، في الفرع part5-2.
ملاحظة حول استخدام عنصر label
استخدمنا عنصر label لحقول input في نموذج تسجيل الدخول. ويوضع حقل input الخاص باسم المستخدم داخل عنصر label المقابل:
<div>
<label>
username
<input
type="text"
value={username}
onChange={({ target }) => setUsername(target.value)}
/>
</label>
</div>
// ...
لماذا نفّذنا النموذج بهذه الطريقة؟ بصرياً، يمكن تحقيق النتيجة نفسها بشيفرة أبسط، دون عنصر label منفصل:
<div>
username
<input
type="text"
value={username}
onChange={({ target }) => setUsername(target.value)}
/>
</div>
// ...
يُستخدم عنصر label في النماذج لوصف حقول input وتسميتها. فهو يوفّر وصفاً لحقل الإدخال، ويساعد المستخدم على فهم المعلومات التي ينبغي إدخالها في كل حقل. ويرتبط هذا الوصف برمجياً بحقل الإدخال المقابل، ما يحسّن إمكانية الوصول في النموذج.
بهذه الطريقة، يمكن لقارئات الشاشة قراءة اسم الحقل للمستخدم عند تحديد حقل الإدخال، كما يؤدي النقر على نص الـ label إلى تركيز المؤشر تلقائياً على حقل الإدخال الصحيح. ويُنصح دائماً باستخدام عنصر label مع حقول input، حتى لو أمكن تحقيق النتيجة البصرية نفسها بدونه.
هناك طرق عدة لربط label معيّن بعنصر input. أسهل طريقة هي وضع عنصر input داخل عنصر label المقابل، كما هو موضّح في هذه المادة. وهذا يربط label تلقائياً بحقل الإدخال الصحيح دون الحاجة إلى أي إعداد إضافي.
إنشاء ملاحظات جديدة
يُحفظ الرمز المُعاد عند نجاح تسجيل الدخول في حالة التطبيق - في الحقل token الخاص بـuser:
const handleLogin = async (event) => {
event.preventDefault()
try {
const user = await loginService.login({
username, password,
})
setUser(user)
setUsername('')
setPassword('')
} catch (exception) {
// ...
}
}
لنصلح إنشاء ملاحظات جديدة ليعمل مع الواجهة الخلفية. يعني هذا إضافة رمز المستخدم المسجّل للدخول إلى ترويسة Authorization في طلب HTTP.
تتغير وحدة noteService كما يلي:
import axios from 'axios'
const baseUrl = '/api/notes'
let token = null
// highlight-start
const setToken = newToken => {
token = `Bearer ${newToken}`
}
// highlight-end
const getAll = () => {
const request = axios.get(baseUrl)
return request.then(response => response.data)
}
const create = async newObject => {
// highlight-start
const config = {
headers: { Authorization: token }
}
// highlight-end
const response = await axios.post(baseUrl, newObject, config)
return response.data
}
const update = (id, newObject) => {
const request = axios.put(`${ baseUrl }/${id}`, newObject)
return request.then(response => response.data)
}
export default { getAll, create, update, setToken }
تحتوي وحدة noteService على متغير خاص باسم token. ويمكن تغيير قيمته بالدالة setToken التي تصدّرها الوحدة. أما create، بصيغة async/await الآن، فتضع الرمز في ترويسة Authorization. وتُمرَّر الترويسة إلى axios كوسيط ثالث في دالة post.
يجب تغيير معالج الحدث المسؤول عن تسجيل الدخول ليستدعي الدالة noteService.setToken(user.token) عند نجاح تسجيل الدخول:
const handleLogin = async (event) => {
event.preventDefault()
try {
const user = await loginService.login({ username, password })
noteService.setToken(user.token)
setUser(user)
setUsername('')
setPassword('')
} catch {
// ...
}
}
والآن أصبحت إضافة ملاحظات جديدة تعمل من جديد!
حفظ الرمز في التخزين المحلي للمتصفح
في تطبيقنا عيب صغير: إذا حُدِّث المتصفح (مثلاً بالضغط على F5)، تختفي معلومات تسجيل دخول المستخدم.
تُحل هذه المشكلة بسهولة بحفظ تفاصيل تسجيل الدخول في التخزين المحلي. التخزين المحلي قاعدة بيانات مفتاح-قيمة في المتصفح.
استخدامه سهل جداً. تُحفظ قيمة تقابل مفتاحاً معيناً في قاعدة البيانات بالدالة setItem. مثلاً:
window.localStorage.setItem('name', 'juha tauriainen')
يحفظ النص المُمرَّر كوسيط ثانٍ كقيمة للمفتاح name.
ويمكن إيجاد قيمة مفتاح بالدالة getItem:
window.localStorage.getItem('name')
بينما تحذف removeItem مفتاحاً.
تبقى القيم في التخزين المحلي محفوظة حتى عند إعادة عرض الصفحة. والتخزين خاص بـالأصل (origin) لذا لكل تطبيق ويب تخزينه الخاص.
لنوسّع تطبيقنا بحيث يحفظ تفاصيل المستخدم المسجّل للدخول في التخزين المحلي.
القيم المحفوظة في التخزين هي DOMstrings، لذا لا يمكننا حفظ كائن JavaScript كما هو. يجب أولاً تحويل الكائن إلى JSON بالدالة JSON.stringify. وبالمقابل، عند قراءة كائن JSON من التخزين المحلي، يجب تحويله مرة أخرى إلى JavaScript بالدالة JSON.parse.
التغييرات على دالة تسجيل الدخول كما يلي:
const handleLogin = async (event) => {
event.preventDefault()
try {
const user = await loginService.login({ username, password })
// highlight-start
window.localStorage.setItem(
'loggedNoteappUser', JSON.stringify(user)
)
// highlight-end
noteService.setToken(user.token)
setUser(user)
setUsername('')
setPassword('')
} catch (exception) {
// ...
}
}
تُحفظ الآن تفاصيل المستخدم المسجّل للدخول في التخزين المحلي، ويمكن عرضها في وحدة التحكم (بكتابة window.localStorage فيها):

يمكنك أيضاً فحص التخزين المحلي باستخدام أدوات المطور. في Chrome، انتقل إلى تبويب Application واختر Local Storage (تفاصيل أكثر هنا). وفي Firefox انتقل إلى تبويب Storage واختر Local Storage (التفاصيل هنا).
ما زال علينا تعديل تطبيقنا بحيث يتحقق عند دخول الصفحة مما إذا كانت تفاصيل مستخدم مسجّل للدخول موجودة بالفعل في التخزين المحلي. وإذا كانت موجودة، تُحفظ التفاصيل في حالة التطبيق وفي noteService.
الطريقة الصحيحة لفعل ذلك هي باستخدام خطاف تأثير (effect hook): وهي آلية صادفناها أول مرة في الجزء 2، واستخدمناها لجلب الملاحظات من الخادم.
يمكن أن يكون لدينا عدة خطافات تأثير، لذا لننشئ خطافاً ثانياً للتعامل مع التحميل الأول للصفحة:
const App = () => {
const [notes, setNotes] = useState([])
const [newNote, setNewNote] = useState('')
const [showAll, setShowAll] = useState(true)
const [errorMessage, setErrorMessage] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [user, setUser] = useState(null)
useEffect(() => {
noteService.getAll().then(initialNotes => {
setNotes(initialNotes)
})
}, [])
// highlight-start
useEffect(() => {
const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser')
if (loggedUserJSON) {
const user = JSON.parse(loggedUserJSON)
setUser(user)
noteService.setToken(user.token)
}
}, [])
// highlight-end
// ...
}
تضمن المصفوفة الفارغة كوسيط للخطاف أن يُنفَّذ الخطاف فقط عند عرض المكوّن للمرة الأولى.
الآن يبقى المستخدم مسجّلاً للدخول إلى التطبيق إلى الأبد. ربما ينبغي لنا إضافة وظيفة تسجيل الخروج التي تزيل تفاصيل تسجيل الدخول من التخزين المحلي. لكننا سنتركها كتمرين.
يمكن تسجيل خروج المستخدم باستخدام وحدة التحكم، وهذا يكفي الآن. يمكنك تسجيل الخروج بالأمر:
window.localStorage.removeItem('loggedNoteappUser')
أو بالأمر الذي يفرّغ localstorage بالكامل:
window.localStorage.clear()
يمكن العثور على شيفرة التطبيق الحالية على GitHub، في الفرع part5-3.
تمارين 5.1.-5.4.
سننشئ الآن واجهة أمامية للواجهة الخلفية لقائمة المدونات التي أنشأناها في الجزء الماضي. يمكنك استخدام هذا التطبيق من GitHub كأساس لحلك. وتحتاج إلى ربط واجهتك الخلفية بوسيط (proxy) كما هو موضّح في الجزء 3.
يكفي إرسال حلك النهائي. يمكنك عمل commit بعد كل تمرين، لكن ذلك ليس ضرورياً.
تُراجع التمارين الأولى كل ما تعلمناه عن React حتى الآن. وقد تكون صعبة، خاصة إذا كانت واجهتك الخلفية غير مكتملة. وقد يكون من الأفضل استخدام الواجهة الخلفية التي وضعناها كحل للجزء 4.
أثناء حل التمارين، تذكّر كل طرق تصحيح الأخطاء التي تحدثنا عنها، وخاصة مراقبة وحدة التحكم.
تحذير: إذا لاحظت أنك تخلط بين أوامر async/await و_then_، فمن المؤكد بنسبة 99.9% أنك تفعل شيئاً خاطئاً. استخدم أحدهما فقط، ولا تستخدم كليهما أبداً.
5.1: واجهة قائمة المدونات الأمامية، الخطوة 1
استنسخ التطبيق من GitHub بالأمر:
git clone https://github.com/fullstack-hy2020/bloglist-frontend
أزل إعدادات git من التطبيق المستنسخ
cd bloglist-frontend // انتقل إلى المستودع المستنسخ
rm -rf .git
يُشغَّل التطبيق بالطريقة المعتادة، لكن عليك تثبيت اعتمادياته أولاً:
npm install
npm run dev
نفّذ وظيفة تسجيل الدخول في الواجهة الأمامية. يُحفظ الرمز المُعاد عند نجاح تسجيل الدخول في حالة التطبيق user.
إذا لم يكن المستخدم مسجّلاً للدخول، فلا يظهر سوى نموذج تسجيل الدخول.

وإذا كان المستخدم مسجّلاً للدخول، يُعرض اسم المستخدم وقائمة المدونات.

لا حاجة بعد إلى حفظ تفاصيل المستخدم المسجّل للدخول في التخزين المحلي.
ملاحظة يمكنك تنفيذ العرض الشرطي لنموذج تسجيل الدخول هكذا مثلاً:
if (user === null) {
return (
<div>
<h2>Log in to application</h2>
<form>
//...
</form>
</div>
)
}
return (
<div>
<h2>blogs</h2>
{blogs.map(blog =>
<Blog key={blog.id} blog={blog} />
)}
</div>
)
}
5.2: واجهة قائمة المدونات الأمامية، الخطوة 2
اجعل تسجيل الدخول «دائماً» باستخدام التخزين المحلي. ونفّذ أيضاً طريقة لتسجيل الخروج.

تأكد من أن المتصفح لا يتذكر تفاصيل المستخدم بعد تسجيل الخروج.
5.3: واجهة قائمة المدونات الأمامية، الخطوة 3
وسّع تطبيقك ليتمكن المستخدم المسجّل للدخول من إضافة مدونات جديدة:

5.4: واجهة قائمة المدونات الأمامية، الخطوة 4
نفّذ إشعارات تُخبر المستخدم بالعمليات الناجحة وغير الناجحة في أعلى الصفحة. مثلاً، عند إضافة مدونة جديدة يمكن عرض الإشعار التالي:

ويمكن أن يعرض تسجيل الدخول الفاشل الإشعار التالي:

يجب أن تبقى الإشعارات ظاهرة لثوانٍ قليلة. وليس إلزامياً إضافة ألوان.
ملاحظة حول استخدام التخزين المحلي
في نهاية الجزء الماضي، ذكرنا أن التحدي في المصادقة القائمة على الرموز هو كيفية التعامل مع الحالة التي يلزم فيها إلغاء وصول حامل الرمز إلى الـ API.
هناك حلان لهذه المشكلة. الأول هو تحديد مدة صلاحية الرمز. وهذا يجبر المستخدم على إعادة تسجيل الدخول إلى التطبيق بعد انتهاء صلاحية الرمز. والنهج الآخر هو حفظ معلومات صلاحية كل رمز في قاعدة بيانات الواجهة الخلفية. ويُسمى هذا الحل غالباً جلسة من جهة الخادم (server-side session).
ومهما كانت طريقة التحقق من صلاحية الرموز وضمانها، فقد ينطوي حفظ الرمز في التخزين المحلي على خطر أمني إذا كان في التطبيق ثغرة أمنية تسمح بهجمات البرمجة عبر المواقع (XSS). وتكون هجمة XSS ممكنة إذا سمح التطبيق للمستخدم بحقن شيفرة JavaScript عشوائية (مثلاً باستخدام نموذج) ينفّذها التطبيق بعد ذلك. وعند استخدام React بحكمة لا ينبغي أن يكون ذلك ممكناً لأن React ينقّي كل النص الذي يعرضه، أي أنه لا ينفّذ المحتوى المعروض كشيفرة JavaScript.
وإذا أراد المرء أن يلعب بأمان، فأفضل خيار هو عدم تخزين الرمز في التخزين المحلي. وقد يكون هذا خياراً في الحالات التي قد يكون فيها تسريب الرمز ذا عواقب كارثية.
وقد اقتُرح حفظ هوية المستخدم المسجّل للدخول في ملفات تعريف ارتباط httpOnly، بحيث لا تستطيع شيفرة JavaScript الوصول إلى الرمز إطلاقاً. وعيب هذا الحل أنه يجعل تنفيذ تطبيقات الصفحة الواحدة (SPA) أكثر تعقيداً بعض الشيء. إذ سيحتاج المرء على الأقل إلى تنفيذ صفحة منفصلة لتسجيل الدخول.
لكن من الجيد ملاحظة أن حتى استخدام ملفات تعريف ارتباط httpOnly لا يضمن أي شيء. بل اقتُرح أن ملفات تعريف ارتباط httpOnly ليست أكثر أماناً من استخدام التخزين المحلي.
لذا، ومهما كان الحل المستخدم، فإن أهم شيء هو تقليل خطر هجمات XSS إلى الحد الأدنى.