Merge pull request #91 from sahat/linkedin

Linkedin OAuth and API demo
This commit is contained in:
Sahat Yalkabov
2014-02-27 17:42:30 -05:00
12 changed files with 476 additions and 202 deletions

View File

@ -177,6 +177,23 @@ app.get('/auth/facebook/callback', passport.authenticate('facebook', { successRe
<hr>
<img src="http://www.danpontefract.com/wp-content/uploads/2014/02/logo-linkedin.png" width="200">
- Sign in at [LinkedIn Developer Network](http://developer.linkedin.com/)
- From the account name dropdown menu select **API Keys**
- *It may ask you to sign in once again*
- Click **+ Add New Application** button
- Fill out all *required* fields
- For **Default Scope** make sure *at least* the following is checked:
- `r_fullprofile`
- `r_emailaddress`
- `r_network`
- Finish by clicking **Add Application** button
- Copy and paste *API Key* and *Secret Key* keys into `config/secrets.js`
- *API Key* is your **clientID**
- *Secret Key* is your **clientSecret**
<hr>
<img src="https://s3.amazonaws.com/venmo/venmo_logo_blue.png" width="200">
- Visit the **Account** section of your Venmo profile after logging in
- Click on the **Developers** tab

3
app.js
View File

@ -132,6 +132,7 @@ app.get('/api/github', passportConf.isAuthenticated, passportConf.isAuthorized,
app.get('/api/twitter', passportConf.isAuthenticated, passportConf.isAuthorized, apiController.getTwitter);
app.get('/api/venmo', passportConf.isAuthenticated, passportConf.isAuthorized, apiController.getVenmo);
app.post('/api/venmo', passportConf.isAuthenticated, passportConf.isAuthorized, apiController.postVenmo);
app.get('/api/linkedin', apiController.getLinkedin);
/**
* OAuth routes for sign-in.
@ -145,6 +146,8 @@ app.get('/auth/google', passport.authenticate('google', { scope: 'profile email'
app.get('/auth/google/callback', passport.authenticate('google', { successRedirect: '/', failureRedirect: '/login' }));
app.get('/auth/twitter', passport.authenticate('twitter'));
app.get('/auth/twitter/callback', passport.authenticate('twitter', { successRedirect: '/', failureRedirect: '/login' }));
app.get('/auth/linkedin', passport.authenticate('linkedin', { state: 'SOME STATE' }));
app.get('/auth/linkedin/callback', passport.authenticate('linkedin', { successRedirect: '/', failureRedirect: '/login' }));
/**
* OAuth routes for API examples that require authorization.

View File

@ -5,6 +5,7 @@ var FacebookStrategy = require('passport-facebook').Strategy;
var TwitterStrategy = require('passport-twitter').Strategy;
var GitHubStrategy = require('passport-github').Strategy;
var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
var LinkedInStrategy = require('passport-linkedin-oauth2').Strategy;
var OAuthStrategy = require('passport-oauth').OAuthStrategy; // Tumblr
var OAuth2Strategy = require('passport-oauth').OAuth2Strategy; // Venmo, Foursquare
var User = require('../models/User');
@ -229,6 +230,59 @@ passport.use(new GoogleStrategy(secrets.google, function(req, accessToken, refre
}
}));
/**
* Sign in with LinkedIn.
*/
passport.use(new LinkedInStrategy(secrets.linkedin, function(req, accessToken, refreshToken, profile, done) {
if (req.user) {
User.findOne({ $or: [
{ linkedin: profile.id },
{ email: profile._json.emailAddress }
] }, function(err, existingUser) {
if (existingUser) {
req.flash('errors', { msg: 'There is already a LinkedIn account that belongs to you. Sign in with that account or delete it, then link it with your current account.' });
done(err);
} else {
User.findById(req.user.id, function(err, user) {
user.linkedin = profile.id;
user.tokens.push({ kind: 'linkedin', accessToken: accessToken });
user.profile.name = user.profile.name || profile.displayName;
user.profile.location = user.profile.location || profile._json.location.name;
user.profile.picture = user.profile.picture || profile._json.pictureUrl;
user.profile.website = user.profile.website || profile._json.publicProfileUrl;
user.save(function(err) {
req.flash('info', { msg: 'LinkedIn account has been linked.' });
done(err, user);
});
});
}
});
} else {
User.findOne({ linkedin: profile.id }, function(err, existingUser) {
if (existingUser) return done(null, existingUser);
User.findOne({ email: profile._json.emailAddress }, function(err, existingEmailUser) {
if (existingEmailUser) {
req.flash('errors', { msg: 'There is already an account using this email address. Sign in to that account and link it with Google manually from Account Settings.' });
done(err);
} else {
var user = new User();
user.linkedin = profile.id;
user.tokens.push({ kind: 'linkedin', accessToken: accessToken });
user.email = profile._json.emailAddress;
user.profile.name = profile.displayName;
user.profile.location = profile._json.location.name;
user.profile.picture = profile._json.pictureUrl;
user.profile.website = profile._json.publicProfileUrl;
user.save(function(err) {
done(err, user);
});
}
});
});
}
}));
/**
* Tumblr API
* Uses OAuth 1.0a Strategy.

View File

@ -14,6 +14,7 @@ var Github = require('github-api');
var Twit = require('twit');
var paypal = require('paypal-rest-sdk');
var twilio = require('twilio')(secrets.twilio.sid, secrets.twilio.token);
var Linkedin = require('node-linkedin')(secrets.linkedin.clientID, secrets.linkedin.clientSecret, secrets.linkedin.callbackURL);
/**
* GET /api
@ -34,31 +35,31 @@ exports.getApi = function(req, res) {
exports.getFoursquare = function(req, res, next) {
var token = _.findWhere(req.user.tokens, { kind: 'foursquare' });
async.parallel({
trendingVenues: function(callback) {
foursquare.Venues.getTrending('40.7222756', '-74.0022724', { limit: 50 }, token.accessToken, function(err, results) {
callback(err, results);
});
},
venueDetail: function(callback) {
foursquare.Venues.getVenue('49da74aef964a5208b5e1fe3', token.accessToken, function(err, results) {
callback(err, results);
});
},
userCheckins: function(callback) {
foursquare.Users.getCheckins('self', null, token.accessToken, function(err, results) {
callback(err, results);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/foursquare', {
title: 'Foursquare API',
trendingVenues: results.trendingVenues,
venueDetail: results.venueDetail,
userCheckins: results.userCheckins
trendingVenues: function(callback) {
foursquare.Venues.getTrending('40.7222756', '-74.0022724', { limit: 50 }, token.accessToken, function(err, results) {
callback(err, results);
});
},
venueDetail: function(callback) {
foursquare.Venues.getVenue('49da74aef964a5208b5e1fe3', token.accessToken, function(err, results) {
callback(err, results);
});
},
userCheckins: function(callback) {
foursquare.Users.getCheckins('self', null, token.accessToken, function(err, results) {
callback(err, results);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/foursquare', {
title: 'Foursquare API',
trendingVenues: results.trendingVenues,
venueDetail: results.venueDetail,
userCheckins: results.userCheckins
});
});
};
/**
@ -92,25 +93,25 @@ exports.getFacebook = function(req, res, next) {
var token = _.findWhere(req.user.tokens, { kind: 'facebook' });
graph.setAccessToken(token.accessToken);
async.parallel({
getMe: function(done) {
graph.get(req.user.facebook, function(err, me) {
done(err, me);
});
},
getMyFriends: function(done) {
graph.get(req.user.facebook + '/friends', function(err, friends) {
done(err, friends.data);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/facebook', {
title: 'Facebook API',
me: results.getMe,
friends: results.getMyFriends
getMe: function(done) {
graph.get(req.user.facebook, function(err, me) {
done(err, me);
});
},
getMyFriends: function(done) {
graph.get(req.user.facebook + '/friends', function(err, friends) {
done(err, friends.data);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/facebook', {
title: 'Facebook API',
me: results.getMe,
friends: results.getMyFriends
});
});
};
/**
@ -187,53 +188,53 @@ exports.getNewYorkTimes = function(req, res, next) {
exports.getLastfm = function(req, res, next) {
var lastfm = new LastFmNode(secrets.lastfm);
async.parallel({
artistInfo: function(done) {
lastfm.request("artist.getInfo", {
artist: 'Epica',
handlers: {
success: function(data) {
done(null, data);
},
error: function(err) {
done(err);
}
artistInfo: function(done) {
lastfm.request("artist.getInfo", {
artist: 'Epica',
handlers: {
success: function(data) {
done(null, data);
},
error: function(err) {
done(err);
}
});
},
artistTopAlbums: function(done) {
lastfm.request("artist.getTopAlbums", {
artist: 'Epica',
handlers: {
success: function(data) {
var albums = [];
_.each(data.topalbums.album, function(album) {
albums.push(album.image.slice(-1)[0]['#text']);
});
done(null, albums.slice(0, 4));
},
error: function(err) {
done(err);
}
}
});
}
},
function(err, results) {
if (err) return next(err.message);
var artist = {
name: results.artistInfo.artist.name,
image: results.artistInfo.artist.image.slice(-1)[0]['#text'],
tags: results.artistInfo.artist.tags.tag,
bio: results.artistInfo.artist.bio.summary,
stats: results.artistInfo.artist.stats,
similar: results.artistInfo.artist.similar.artist,
topAlbums: results.artistTopAlbums
};
res.render('api/lastfm', {
title: 'Last.fm API',
artist: artist
}
});
},
artistTopAlbums: function(done) {
lastfm.request("artist.getTopAlbums", {
artist: 'Epica',
handlers: {
success: function(data) {
var albums = [];
_.each(data.topalbums.album, function(album) {
albums.push(album.image.slice(-1)[0]['#text']);
});
done(null, albums.slice(0, 4));
},
error: function(err) {
done(err);
}
}
});
}
},
function(err, results) {
if (err) return next(err.message);
var artist = {
name: results.artistInfo.artist.name,
image: results.artistInfo.artist.image.slice(-1)[0]['#text'],
tags: results.artistInfo.artist.tags.tag,
bio: results.artistInfo.artist.bio.summary,
stats: results.artistInfo.artist.stats,
similar: results.artistInfo.artist.similar.artist,
topAlbums: results.artistTopAlbums
};
res.render('api/lastfm', {
title: 'Last.fm API',
artist: artist
});
});
};
/**
@ -347,41 +348,41 @@ exports.getSteam = function(req, res, next) {
var query = { l: 'english', steamid: steamId, key: secrets.steam.apiKey };
async.parallel({
playerAchievements: function(done) {
query.appid = '49520';
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
},
playerSummaries: function(done) {
query.steamids = steamId;
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
},
ownedGames: function(done) {
query.include_appinfo = 1;
query.include_played_free_games = 1;
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/steam', {
title: 'Steam Web API',
ownedGames: results.ownedGames.response.games,
playerAchievemments: results.playerAchievements.playerstats,
playerSummary: results.playerSummaries.response.players[0]
playerAchievements: function(done) {
query.appid = '49520';
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
},
playerSummaries: function(done) {
query.steamids = steamId;
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
},
ownedGames: function(done) {
query.include_appinfo = 1;
query.include_played_free_games = 1;
var qs = querystring.stringify(query);
request.get({ url: 'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?' + qs, json: true }, function(error, request, body) {
if (request.statusCode === 401) return done(new Error('Missing or Invalid Steam API Key'));
done(error, body);
});
}
},
function(err, results) {
if (err) return next(err);
res.render('api/steam', {
title: 'Steam Web API',
ownedGames: results.ownedGames.response.games,
playerAchievemments: results.playerAchievements.playerstats,
playerSummary: results.playerSummaries.response.players[0]
});
});
};
/**
@ -482,3 +483,17 @@ exports.postVenmo = function(req, res, next) {
res.redirect('/api/venmo');
});
};
exports.getLinkedin = function(req, res, next) {
var token = _.findWhere(req.user.tokens, { kind: 'linkedin' });
var linkedin = Linkedin.init(token.accessToken);
linkedin.people.me(function(err, $in) {
if (err) return next(err);
console.log($in.positions.values);
res.render('api/linkedin', {
title: 'LinkedIn API',
profile: $in
});
});
};

View File

@ -10,6 +10,7 @@ var userSchema = new mongoose.Schema({
twitter: { type: String, unique: true, sparse: true },
google: { type: String, unique: true, sparse: true },
github: { type: String, unique: true, sparse: true },
linkedin: { type: String, unique: true, sparse: true },
tokens: Array,
profile: {

View File

@ -2,8 +2,8 @@
"name": "hackathon-starter",
"version": "0.0.0",
"repository": {
"type" : "git",
"url" : "https://github.com/sahat/hackathon-starter.git"
"type": "git",
"url": "https://github.com/sahat/hackathon-starter.git"
},
"scripts": {
"start": "node app.js",
@ -14,6 +14,8 @@
"bcrypt-nodejs": "~0.0.3",
"cheerio": "~0.13.1",
"connect-assets": "~3.0.0-beta1",
"connect-mongo": "~0.4.0",
"csso": "~1.3.11",
"express": "~3.4.8",
"express-flash": "~0.0.2",
"express-validator": "~1.0.1",
@ -24,24 +26,24 @@
"less": "~1.6.3",
"mongoose": "~3.8.7",
"node-foursquare": "~0.2.0",
"node-linkedin": "~0.1.4",
"nodemailer": "~0.6.0",
"passport": "~0.2.0",
"passport-facebook": "~1.0.2",
"passport-github": "~0.1.5",
"passport-google-oauth": "~0.1.5",
"passport-linkedin-oauth2": "~1.1.1",
"passport-local": "~0.1.6",
"passport-oauth": "~1.0.0",
"passport-twitter": "~1.0.2",
"paypal-rest-sdk": "~0.6.4",
"request": "~2.34.0",
"tumblr.js": "~0.0.4",
"twilio": "~1.5.0",
"twit": "~1.1.12",
"underscore": "~1.6.0",
"paypal-rest-sdk": "~0.6.4",
"connect-mongo": "~0.4.0",
"twilio": "~1.5.0",
"validator": "~3.3.0",
"csso": "~1.3.11",
"uglify-js": "~2.4.12"
"uglify-js": "~2.4.12",
"validator": "~3.3.0"
},
"devDependencies": {
"chai": "~1.9.0",

140
public/css/lib/bootstrap-social.less vendored Executable file
View File

@ -0,0 +1,140 @@
/*
* Social Buttons for Bootstrap
*
* Copyright 2013-2014 Panayiotis Lipiridis
* Licensed under the MIT License
*
* https://github.com/lipis/bootstrap-social
*/
.btn-social {
@bs-height-base: (@line-height-computed + @padding-base-vertical * 2);
@bs-height-lg: (floor(@font-size-large * @line-height-base) + @padding-large-vertical * 2);
@bs-height-sm: (floor(@font-size-small * 1.5) + @padding-small-vertical * 2);
@bs-height-xs: (floor(@font-size-small * 1.2) + @padding-small-vertical + 1);
position: relative;
padding-left: @bs-height-base + @padding-base-horizontal;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
:first-child {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: @bs-height-base;
line-height: (@bs-height-base + 2);
font-size: 1.6em;
text-align: center;
border-right: 1px solid rgba(0, 0, 0, 0.2);
}
&.btn-lg {
padding-left: @bs-height-lg + @padding-large-horizontal;
:first-child {
line-height: @bs-height-lg;
width: @bs-height-lg;
font-size: 1.8em;
}
}
&.btn-sm {
padding-left: @bs-height-sm + @padding-small-horizontal;
:first-child {
line-height: @bs-height-sm;
width: @bs-height-sm;
font-size: 1.4em;
}
}
&.btn-xs {
padding-left: @bs-height-xs + @padding-small-horizontal;
:first-child {
line-height: @bs-height-xs;
width: @bs-height-xs;
font-size: 1.2em;
}
}
}
.btn-social-icon {
.btn-social;
height: (@bs-height-base + 2);
width: (@bs-height-base + 2);
padding: 0;
:first-child {
border: none;
text-align: center;
width: 100% !important;
}
&.btn-lg {
height: @bs-height-lg;
width: @bs-height-lg;
padding-left: 0;
padding-right: 0;
}
&.btn-sm {
height: (@bs-height-sm + 2);
width: (@bs-height-sm + 2);
padding-left: 0;
padding-right: 0;
}
&.btn-xs {
height: (@bs-height-xs + 2);
width: (@bs-height-xs + 2);
padding-left: 0;
padding-right: 0;
}
}
.btn-social(@color-bg, @color: white) {
background-color: @color-bg;
.button-variant(@color, @color-bg, rgba(0, 0, 0, 0.2));
}
.btn-bitbucket {
.btn-social(#205081);
}
.btn-dropbox {
.btn-social(#1087dd);
}
.btn-facebook {
.btn-social(#3b5998);
}
.btn-flickr {
.btn-social(#ff0084);
}
.btn-foursquare {
.btn-social(#0072b1);
}
.btn-github {
.btn-social(#444444);
}
.btn-google-plus {
.btn-social(#dd4b39);
}
.btn-instagram {
.btn-social(#3f729b);
}
.btn-linkedin {
.btn-social(#007bb6);
}
.btn-tumblr {
.btn-social(#2c4762);
}
.btn-twitter {
.btn-social(#55acee);
}
.btn-vk {
.btn-social(#587ea3);
}

View File

@ -1,6 +1,7 @@
@import (less) "lib/animate.css";
@import (less) "lib/font-awesome.min.css";
@import "lib/bootstrap/bootstrap";
@import "lib/bootstrap-social";
@import "themes/default";
// Scaffolding
@ -50,49 +51,6 @@ body {
height: 45px;
}
// Social Buttons
// -------------------------
.btn-facebook {
color: #fff;
background: #3b5998;
border: 1px solid rgba(0, 0, 0, 0.07);
&:hover {
color: #fff;
}
}
.btn-twitter {
color: #fff;
background: #00aced;
border: 1px solid rgba(0, 0, 0, 0.07);
&:hover {
color: #fff;
}
}
.btn-google-plus {
color: #fff;
background: #dd4b39;
border: 1px solid rgba(0, 0, 0, 0.07);
&:hover {
color: #fff;
}
}
.btn-github {
color: #fff;
background: #333;
border: 1px solid rgba(0, 0, 0, 0.07);
&:hover {
color: #fff;
}
}
// Extra space between font-awesome icons and text
[class^="fa-"],
[class*="fa-"] {
@ -119,7 +77,7 @@ body {
.facebook-caption {
position: absolute;
background-color: rgba(0,0,0,0.5);
background-color: rgba(0, 0, 0, 0.5);
font-size: 12px;
color: #fff;
padding: 3px;

View File

@ -1,37 +1,41 @@
extends ../layout
block content
.col-sm-8.col-sm-offset-2
form(method='POST')
legend Sign In
input(type='hidden', name='_csrf', value=token)
.form-group
.btn-group.btn-group-justified
if secrets.facebookAuth
a.btn.btn-facebook(href='/auth/facebook')
i.fa.fa-facebook
| Facebook
if secrets.twitterAuth
a.btn.btn-twitter(href='/auth/twitter')
i.fa.fa-twitter
| Twitter
if secrets.githubAuth
a.btn.btn-github(href='/auth/github')
i.fa.fa-github
| GitHub
if secrets.googleAuth
a.btn.btn-google-plus(href='/auth/google')
i.fa.fa-google-plus
| Google
form(method='POST')
legend Sign In
input(type='hidden', name='_csrf', value=token)
.row
.col-sm-4
if secrets.facebookAuth
a.btn.btn-block.btn-facebook.btn-social(href='/auth/facebook')
i.fa.fa-facebook
| Sign in with Facebook
if secrets.twitterAuth
a.btn.btn-block.btn-twitter.btn-social(href='/auth/twitter')
i.fa.fa-twitter
| Sign in with Twitter
if secrets.googleAuth
a.btn.btn-block.btn-google-plus.btn-social(href='/auth/google')
i.fa.fa-google-plus
| Sign in with Google
if secrets.githubAuth
a.btn.btn-block.btn-github.btn-social(href='/auth/github')
i.fa.fa-github
| Sign in with GitHub
if secrets.linkedinAuth
a.btn.btn-block.btn-linkedin.btn-social(href='/auth/linkedin')
i.fa.fa-linkedin
| Sign in with LinkedIn
if secrets.localAuth
.form-group
label.control-label(for='email') Email
input.form-control(type='text', name='email', id='email', placeholder='Email', autofocus=true)
.form-group
label.control-label(for='password') Password
input.form-control(type='password', name='password', id='password', placeholder='Password')
.form-group
button.btn.btn-primary(type='submit')
i.fa.fa-unlock-alt
| Login
a.btn.btn-link(href='/forgot') Forgot your password?
.col-sm-8
.form-group
label.control-label(for='email') Email
input.form-control(type='text', name='email', id='email', placeholder='Email', autofocus=true)
.form-group
label.control-label(for='password') Password
input.form-control(type='password', name='password', id='password', placeholder='Password')
.form-group
button.btn.btn-primary(type='submit')
i.fa.fa-unlock-alt
| Login
a.btn.btn-link(href='/forgot') Forgot your password?

View File

@ -98,3 +98,9 @@ block content
p: a.text-danger(href='/account/unlink/github') Unlink your GitHub account
else
p: a(href='/auth/github') Link your GitHub account
if secrets.linkedinAuth
if user.linkedin
p: a.text-danger(href='/account/unlink/linkedin') Unlink your LinkedIn account
else
p: a(href='/auth/linkedin') Link your LinkedIn account

View File

@ -17,6 +17,9 @@ block content
small ⇢ Login Required
li
a(href='/api/lastfm') Last.fm
li
a(href='/api/linkedin') LinkedIn
small ⇢ Login Required
li
a(href='/api/nyt') New York Times
li

71
views/api/linkedin.jade Normal file
View File

@ -0,0 +1,71 @@
extends ../layout
block content
.page-header
h2
i.fa.fa-linkedin-square
| LinkedIn API
.btn-group.btn-group-justified
a.btn.btn-primary(href='https://github.com/Kuew/node-linkedin', target='_blank')
i.fa.fa-book
| Node LinkedIn Docs
a.btn.btn-primary(href='http://developer.linkedin.com/documents/authentication', target='_blank')
i.fa.fa-check-square-o
| Getting Started
a.btn.btn-primary(href='http://developer.linkedin.com/apis', target='_blank')
i.fa.fa-code-fork
| API Endpoints
h3.text-primary My LinkedIn Profile
.well.well-sm
.row
.col-sm-12
.col-sm-2
br
img.thumbnail(src='#{profile.pictureUrl}')
.col-sm-10
h3= profile.formattedName
h4= profile.headline
span.text-muted #{profile.location.name} | #{profile.industry}
br
.row
.col-sm-12
dl.dl-horizontal
dt.text-muted Current
for company in profile.positions.values
if company.isCurrent
dd
strong= company.title
| at
strong #{company.company.name}
dt.text-muted Previous
for company in profile.positions.values
if !company.isCurrent
dd
| #{company.title}
| at
| #{company.company.name}
dt.text-muted Education
for education in profile.educations.values
dd= education.schoolName
dt.text-muted Recommendations
dd
strong #{profile.numRecommenders}
| recommendation(s) received
dt.text-muted Connections
dd
strong #{profile.numConnections}
| connections
.text-center
small.text-muted= profile.publicProfileUrl
h3.text-primary LinkedIn Connections
table.table.table-hover.table-striped.table-bordered
tbody
for connection in profile.connections.values
if connection.id != 'private'
tr
td
strong #{connection.firstName} #{connection.lastName}
.text-muted #{connection.headline}